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 — upstream h3 + quinn behind a feature flag, with unsupported protocol edges failing closed instead of requiring a fork.

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 timingsNoObserver

Getting Started

Installation

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

[dependencies]
aioduct = { version = "0.2.5", features = ["tokio"] }

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

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

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

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

For JSON serialization/deserialization:

[dependencies]
aioduct = { version = "0.2.5", 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_lifetime(Duration::from_secs(600))
    .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-sysExperimentalCompatible browser/worker Fetch runtime
wasi-p2wasiExperimentalWASI Preview 2 guest HTTP client
wasmtimewasmtime, wasmtime-wasi, wasmtime-wasi-httpExperimentalWasmtime host-side WASI HTTP adapter
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
blockingselected native runtimeStableSynchronous wrapper for Tokio, smol, or compio clients
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
precise-timingnoneStableUse std::time::Instant instead of the default coarse clock for sub-millisecond observer and timeout measurements
http3h3, quinnExperimentalHTTP/3 transport; currently requires Tokio, rustls, and 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, wasm, or wasi-p2

Common Feature Combinations

# HTTP only, tokio runtime
aioduct = { version = "0.2.5", features = ["tokio"] }

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

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

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

# HTTP only, smol runtime
aioduct = { version = "0.2.5", features = ["smol"] }

# HTTPS, smol runtime
aioduct = { version = "0.2.5", features = ["smol", "rustls", "rustls-ring"] }

# HTTP only, compio runtime (experimental)
aioduct = { version = "0.2.5", features = ["compio"] }

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

# Blocking client (select one native runtime; Tokio shown)
aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring", "blocking"] }

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

# With tower integration
aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring", "tower"] }

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

# DNS-over-HTTPS
aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring", "doh"] }

# DNS-over-TLS
aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring", "dot"] }

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

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

Wasmtime Host Adapter

Wasmtime host integration is first-party under aioduct::wasmtime. Enable the wasmtime feature together with the host runtime and TLS provider:

# Wasmtime host adapter with tokio + rustls/ring
aioduct = { version = "0.2.5", features = ["wasmtime", "tokio", "rustls", "rustls-ring"] }

# Wasmtime host adapter with smol + rustls/ring
aioduct = { version = "0.2.5", features = ["wasmtime", "smol", "rustls", "rustls-ring"] }

# Wasmtime host adapter with compio + rustls/ring
aioduct = { version = "0.2.5", features = ["wasmtime", "compio", "rustls", "rustls-ring"] }

aioduct::wasmtime accepts native RuntimePoll transports such as TokioClient and SmolClient. It also provides CompioHostTransport, which owns a local-runtime worker bridge for CompioClient builders. It does not enable browser wasm, because browser Fetch has no Wasmtime host hook. See examples/wasmtime-host for runnable host examples for each native forwarding transport.

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                  # Public exports, feature gates, client aliases
  client/                 # Engines, builders, request flow, dispatch, replay,
                          # connection deadlines, and proxy establishment
  request/                # RequestBuilderSend and RequestBuilderLocal
  response/               # Response and body transforms/consumption
  body/                   # Buffered and streaming request/response bodies
  forward/                # Forward builders, dispatch plans, targets, headers,
                          # and trailer policy
  proxy/                  # Proxy configuration, immutable routes, chains,
                          # bypass rules, and establishment plans
  connector/              # ConnectorSend and ConnectorLocal
  runtime/                # Runtime traits, executors, resolvers, and adapters
  pool/                   # Pool keys, connection handles, and accounting
  tls/                    # TLS traits plus the rustls connector state machine
  h3/                     # HTTP/3 request lifecycle and Quinn adapter
  message_signatures/     # RFC 9421 parsing, signing, and verification
  upgrade/                # UpgradedSend and UpgradedLocal
  chunk_download/         # Send/local parallel range downloaders
  cache/ cookie/ sse/     # Higher-level HTTP facilities
  wasm.rs / wasi_p2.rs    # Platform-managed guest transports
  wasmtime/               # Host-side WASI HTTP adapter

The tree above shows ownership boundaries rather than every supporting module. Protocol helpers such as HTTP/2 configuration, framing validation, SOCKS handshakes, redirects, digest authentication, and observability remain top-level modules where they are shared by several dispatch paths.

Request Flow

A request in aioduct goes through these stages:

client.get("http://example.com/path")?
  -> RequestBuilderSend (method, URI, headers, body, protocol, timeouts)
  -> RequestBuilderSend::send()
    -> apply HSTS, default headers, cookies, middleware, and cache validators
    -> classify body replayability and finalize digest/signature metadata
    -> capture the finalized request state used by eligible retries
    -> for each redirect, digest retry, or configured retry attempt:
      -> resolve one immutable ProxyDispatchRoute
      -> select the exact/negotiable protocol and full pool key
      -> check out a matching H1, H2, or H3 transport
      -> on a pool miss, run one connection-acquisition deadline across
         coordination, DNS, TCP/QUIC, proxy negotiation, TLS, and handshake
      -> send the request with evidence-gated stale-connection recovery
      -> supervise upload completion and return response headers
    -> apply response middleware, redirects, cookies, cache policy,
       decompression, read timeout, and bandwidth limiting
  -> ResponseBodySend

The Local engine follows the same policy with RequestBuilderLocal, local futures, and local connector/transport implementations. Forwarding bypasses ordinary client middleware but enters the same dispatch layer after its upstream target, protocol, and hop-field policy have been finalized. See Request Dispatch Guarantees for replay, proxy, timeout, and protocol boundaries.

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.
  • HTTP/2 task executors: Separate internal PollExecutor and CompletionExecutor implementations delegate to the active runtime’s spawn_send or spawn_local operation. Both use PhantomData<fn() -> R> so the executor type does not inherit unnecessary ownership or auto-trait bounds from the runtime marker.

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 key contains (scheme, authority, protocol hint, proxy route, forced transport endpoint, effective HTTP/3 endpoint). The complete proxy route keeps direct connections separate from each distinct proxy configuration. Forced addresses cannot satisfy ordinary checkouts or requests forced to another address, and the HTTP/3 endpoint prevents an Alt-Svc change from reusing a QUIC connection to an older endpoint.

Connections are stored in a VecDeque per full key. On checkout, expired connections are evicted. On checkin, the pool respects max_idle_per_host. HTTP/2 and HTTP/3 connections can be shared across concurrent requests because they multiplex 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 uses one shared completion trait with separate Send-capable and thread-local execution traits:

#![allow(unused)]
fn main() {
pub trait RuntimeCompletion: 'static {
    type Sleep: Future<Output = ()>;

    fn sleep(duration: Duration) -> Self::Sleep;
    fn block_on<F: Future>(future: F) -> Result<F::Output, aioduct::Error>;
}

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

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

RuntimeCompletion (Base)

The foundation trait. It defines the runtime’s sleep future and provides block_on to drive a future to completion on a new runtime instance. Runtime construction can fail, so block_on returns Result. Every native runtime implements this trait.

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.

Its RuntimeCompletion::Sleep future must also be Send.

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.

Its RuntimeCompletion::Sleep future does not need to be Send.

Implemented by CompioRuntime.

Connector Traits

Networking is decoupled from the runtime via connector traits. DNS resolution happens before connector dispatch, so each connector establishes a stream to a pre-resolved SocketAddr.

#![allow(unused)]
fn main() {
pub trait ConnectorSend: Clone + Send + Sync + 'static {
    type Stream: hyper::rt::Read
        + hyper::rt::Write
        + SocketConfig
        + Unpin
        + Send
        + 'static;

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

pub trait ConnectorLocal: 'static {
    type Stream: hyper::rt::Read
        + hyper::rt::Write
        + SocketConfig
        + Unpin
        + 'static;

    async fn connect(&self, addr: SocketAddr) -> io::Result<Self::Stream>;
}
}

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.

Both connector traits also support connect_bound for a requested local address and from_std_tcp for adopting a socket created by Happy Eyeballs.

SocketConfig

Connector stream types implement SocketConfig. The engine uses that trait after connection establishment to apply TCP keepalive, TCP Fast Open, and interface binding where the platform supports them. Destination addresses and local bind addresses are passed to connector methods rather than stored in a configuration object.

Built-in Implementations

TokioRuntime + TcpConnector

Enabled with features = ["tokio"].

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

let client = TokioClient::new();
}
  • TokioRuntime creates a current-thread runtime for block_on, rejects blocking use from inside an active Tokio runtime, and implements RuntimePoll with tokio::spawn plus 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 RuntimeCompletion and 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 creates a compio_runtime::Runtime for block_on, uses compio_runtime::spawn for local tasks, and wraps async_io::Timer for the shared runtime sleep contract.
  • 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.

HTTP/2 Task Executors

hyper’s HTTP/2 client handshake requires an Executor to drive background connection tasks. aioduct uses separate internal executors for the two runtime models: PollExecutor<R> delegates to RuntimePoll::spawn_send, while CompletionExecutor<R> delegates to RuntimeLocal::spawn_local.

Both use PhantomData<fn() -> R> so the executor type does not inherit unnecessary ownership or auto-trait bounds from the runtime marker. These executors are internal connection-lifecycle machinery, not part of the public runtime trait contract.

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.

WASM/WASI Runtime Parity

This document compares HTTP client capabilities across aioduct’s five runtime backends: tokio, smol, compio (native), wasm (compatible host Fetch API), and wasi-p2 (WASI Preview 2 wasi:http/outgoing-handler).

Markers:

MarkerMeaning
Library-supported (with cfg feature cited)
Not available
Platform-managed (delegated to host runtime)
Not applicable

Numbered footnotes explain which platform owns each ⚠ capability and name the relevant implementation module or symbol. Feature flags in backticks cite the cfg gate enabling the capability.

Feature Comparison

HTTP Methods

Featuretokiosmolcompiowasmwasi-p2
GET, POST, HEAD, PUT, DELETE, PATCHtokiosmolcompiowasmwasi-p2
Custom methodtokiosmolcompiowasmwasi-p2

All backends support the six standard methods plus an arbitrary-method request() entry point through WasmClient or WasiClient.

Request Headers

Featuretokiosmolcompiowasmwasi-p2
Set (per-request)tokiosmolcompiowasmwasi-p2
Override (batch)tokiosmolcompiowasmwasi-p2
Default headerstokiosmolcompiowasmwasi-p2

All backends support setting headers per request and configuring default headers on their client builder. The platform implementations live on WasmRequestBuilder and WasiRequestBuilder.

HTTP Message Signatures

Featuretokiosmolcompiowasmwasi-p2
RFC 9421 request/response signature-base helpers
RFC 9421 request/response verification policy
RFC 9421 Accept-Signature parser/builder/fulfillment configs
RFC 9421 caller-supplied ;tr trailer field components
Covered Content-Digest verification with caller-supplied body bytes
SHA-256 Content-Digest value helpers for explicit headers
Automatic buffered request Content-Digest generationtokiosmolcompio✗ manual headers only✗ manual headers only
Forward bounded response Content-Digest generationtokiosmolcompio✗ no native forwarding✗ no native forwarding
Sync automatic request signingtokiosmolcompio✗ manual headers only✗ manual headers only
Async automatic request signingSend futureSend future✓ local future✗ manual headers only✗ manual headers only
Forward automatic response signing✓ sync/Send async✓ sync/Send async✓ sync/local async✗ no native forwarding✗ no native forwarding
Automatic trailer-based digest/signature generation✗ manual contexts only✗ manual contexts only✗ manual contexts only✗ manual contexts only✗ manual contexts only

message_signatures is portable: callers can build request or response signature bases, sign them with their own key material, verify signed request or response headers with caller-owned cryptography, parse or build Accept-Signature, convert accepted entries into signing configs, cover caller-supplied trailer fields with ;tr, verify covered SHA-256 Content-Digest fields when callers attach body bytes to verification contexts, format SHA-256 Content-Digest values for explicit headers, and attach Signature-Input / Signature through the normal header APIs on every runtime. Native clients can also generate SHA-256 Content-Digest for buffered request bodies before signing. Native automatic request signing supports sync signers plus async send-runtime signers for tokio/smol and async local-runtime signing futures for compio. It runs after default headers, cookies, cache validators, middleware, digest-auth retry headers, forwarding request rewrites, framing cleanup, and digest insertion have finalized each request attempt. Native forward builders can also buffer downstream responses up to a caller cap to generate Content-Digest before signing downstream responses after response cleanup and on_response; this is not available on browser Fetch or WASI because those targets do not expose the native forwarding builder. Browser Fetch and WASI request dispatch do not expose automatic digest/signing hooks; use the portable helper flow and manual headers there. Automatic trailer-based digest or signature generation is not exposed on any runtime: native HTTP/1 and HTTP/2 can carry request trailer frames, but native HTTP/3 streams request bodies while failing closed if either direction emits trailers, browser Fetch and WASI do not expose matching request-trailer hooks, and forward response signing runs before the downstream response body is streamed. Use caller-supplied trailer maps with the portable context APIs instead.

Authentication

Featuretokiosmolcompiowasmwasi-p2
Bearer tokentokiosmolcompiowasmwasi-p2
Basic authtokiosmolcompiowasmwasi-p2
Digest auth
Netrc

WasmRequestBuilder and WasiRequestBuilder implement bearer_auth() and basic_auth() by setting the Authorization header. basic_auth() uses base64 encoding, matching the native implementation.

Digest auth and netrc-based auth are not integrated into WASM or WASI-P2 clients — the portable types compile but are not wired into the request flow.

URL Query Parameters

Featuretokiosmolcompiowasmwasi-p2
query() (string pairs)tokiosmolcompiowasmwasi-p2
query_serde() (serialize)jsonjsonjson

WASM: wasm.rs query() percent-encodes key/value pairs and appends them to the request URI, matching the native implementation. query_serde() is not available — the serde_urlencoded crate is not a dependency in the wasm target configuration.

WASI-P2: wasi_p2.rs query() percent-encodes and appends pairs identically. query_serde() is likewise not available.

Request Body

Featuretokiosmolcompiowasmwasi-p2
Buffered (body())tokiosmolcompiowasmwasi-p2
JSON (json())jsonjsonjsonjsonjson
Streaming
Multipart/form-data
Form (urlencoded string pairs)wasmwasi-p2
Form (serializable value)jsonjsonjson

WASM: wasm.rs accepts impl Into<Bytes> via body() and URL-encoded string forms via form(). No streaming body or multipart integration. JSON serialization is available with cfg(feature = "json").

WASI-P2: wasi_p2.rs accepts impl Into<Bytes> via body() and URL-encoded string forms via form(). No streaming or multipart integration. JSON is available with cfg(feature = "json").

Native runtimes support streaming bodies via RequestBodySend or RequestBodyLocal, multipart via the multipart module, string-pair form encoding, and serializable form values through serde_urlencoded.

Response Body

Featuretokiosmolcompiowasmwasi-p2
bytes()wasmwasi-p2
text()wasmwasi-p2
json()jsonjsonjsonjsonjson
Streaming (into_bytes_stream())tokiosmolcompiowasm⚠ sync-only [1]
SSE (Server-Sent Events)

[1] WASI-P2: WasiResponse::into_bytes_stream() returns WasiBodyStream, which uses the WASI input stream’s blocking_read operation internally. Its next() operation blocks the calling thread and is not an asynchronous poll. This is adequate for simple single-threaded WASI guests but not for concurrent workloads.

WASM: WasmResponse::into_bytes_stream() returns WasmBodyStream, which wraps the browser’s ReadableStream and waits asynchronously through JsFuture.

SSE: The SseDecoder (portable) can parse event streams from raw bytes on any target. However, the streaming SseStream<B> type requires B: Body<Data = Bytes, Error = Error>, which the WASM/WASI body stream types do not implement. Feed bytes through SseDecoder manually on these targets.

Redirects

Featuretokiosmolcompiowasmwasi-p2
Follow⚠ browser-managed [2]
Max redirects⚠ browser-managed [2]
Custom policy

[2] WASM: WasmRequestBuilder::send() creates a web_sys::Request without overriding RequestInit.redirect, so the browser’s default follow mode applies. The user cannot inspect or control the redirect count or apply a custom policy. The portable RedirectPolicy type is not wired into WasmClient.

WASI-P2: wasi_p2.rs has no redirect handling; the response is returned as-is. The RedirectPolicy type is not integrated.

Native runtimes execute redirects in the client engine (gated behind #[cfg(not(target_arch = "wasm32"))]), configurable via RedirectPolicy.

Cookies

Featuretokiosmolcompiowasmwasi-p2
Cookie jar (store/apply)⚠ browser-managed [3]
Set-Cookie handling⚠ browser-managed [3]

[3] WASM: Cookie handling follows the browser’s Fetch credentials and CORS policy. Same-origin cookies use the browser-managed cookie store by default; cross-origin behavior depends on browser policy, and WasmClient does not currently expose a credentials-mode control. The portable CookieJar module compiles on WASM but is not integrated into WasmClient, and forbidden Set-Cookie response fields are not exposed to application code.

WASI-P2: No cookie jar integration. The CookieJar type is available as a portable module for manual use.

Timeout

Featuretokiosmolcompiowasmwasi-p2
Request timeouttokiosmolcompio⚠ AbortController [4]⚠ WASI-mapped [5]
Connect timeouttokiosmolcompio✗ explicit error [4]⚠ WASI-mapped [5]
Read timeouttokiosmolcompio✗ explicit error [4]⚠ WASI-mapped [5]

[4] WASM: WasmRequestBuilder::send() creates an AbortController, attaches its signal to RequestInit, and schedules controller.abort() through the available Window or Worker timer API. This is one request-level timeout; finer-grained connect/read timeouts are unavailable. Calling those builder controls records an unsupported-operation error returned by send().

[5] WASI-P2: the user’s request timeout is converted to nanoseconds and passed to all three WASI RequestOptions fields: connect_timeout, first_byte_timeout, and between_bytes_timeout. Per-request connect_timeout() overrides the connect field, and read_timeout() maps to the between_bytes_timeout field. Enforcement is delegated to the WASI runtime (e.g., wasmtime).

Proxy

Featuretokiosmolcompiowasmwasi-p2
HTTP proxy
HTTPS proxyrustlsrustlsrustls
SOCKS proxy
System proxy

WASM: The browser Fetch API does not expose proxy configuration to JavaScript. Proxies must be configured at the browser or OS level. The proxy module types are portable but not applicable.

WASI-P2: The wasi:http/outgoing-handler interface has no proxy concept. The proxy module types compile but are not integrated.

DNS

Featuretokiosmolcompiowasmwasi-p2
Custom resolverhickory-dnshickory-dnshickory-dns⚠ browser-managed [6]⚠ WASI-managed [7]
System resolver⚠ browser-managed [6]⚠ WASI-managed [7]

[6] WASM: The browser resolves DNS internally. The web_sys::Request and fetch() interfaces provide no DNS configuration hooks.

[7] WASI-P2: DNS is resolved by the WASI runtime, such as Wasmtime. The wasi:http/outgoing-handler interface does not expose DNS resolver configuration.

TLS

Featuretokiosmolcompiowasmwasi-p2
rustlsrustlsrustlsrustls⚠ browser-managed [8]⚠ WASI-managed [9]
Platform-native certsrustls-native-rootsrustls-native-rootsrustls-native-roots
Client certificatesrustlsrustlsrustls

[8] WASM: The browser’s Fetch API handles TLS negotiation automatically. No TLS configuration is exposed, and the native tls module is unavailable on wasm32.

[9] WASI-P2: The WASI runtime manages TLS through wasi:http/outgoing-handler. No client-certificate or TLS-version configuration is available through WasiClient.

Connection Pooling

Featuretokiosmolcompiowasmwasi-p2
Keep-alivetokiosmolcompio⚠ browser-managed [10]⚠ WASI-managed [11]
max_idle_per_hosttokiosmolcompio⚠ browser-managed [10]⚠ WASI-managed [11]
idle_timeouttokiosmolcompio⚠ browser-managed [10]⚠ WASI-managed [11]
max_lifetimetokiosmolcompio⚠ browser-managed [10]⚠ WASI-managed [11]

[10] WASM: The browser manages HTTP connection pools internally. No pool configuration is exposed, and the native pool is unavailable on wasm32.

[11] WASI-P2: The WASI runtime manages connection pooling behind wasi:http/outgoing-handler; WasiClient exposes no pool controls.

Retry

Featuretokiosmolcompiowasmwasi-p2
Retry config (max, backoff)
Retry budget
Retry-After parsing

WASM + WASI-P2: The RetryConfig, RetryBudget, and parse_retry_after types (retry.rs) are portable and compile on all targets. However, they are not integrated into WasmClient or WasiClient — neither client has a retry loop. Native runtimes integrate retry in the client engine (#[cfg(not(target_arch = "wasm32"))]).

Middleware

Featuretokiosmolcompiowasmwasi-p2
on_request
on_response
on_error
on_redirect
on_retry

WASM + WASI-P2: The Middleware trait and MiddlewareStack (middleware.rs) are portable and compile on all targets. However, they are not integrated into WasmClient or WasiClient — neither client exposes a middleware push API or applies the stack during request/response processing.

Compression

Featuretokiosmolcompiowasmwasi-p2
gzipgzipgzipgzip⚠ browser-managed [12]
brotlibrotlibrotlibrotli⚠ browser-managed [12]
zstdzstdzstdzstd⚠ browser-managed [12]
deflatedeflatedeflatedeflate⚠ browser-managed [12]

[12] WASM: The browser fetch API automatically sets Accept-Encoding and decompresses response bodies. The decompress.rs module is portable but not wired into WasmClient — the browser handles it transparently. No per-codec configuration is possible. Calling no_decompression() records an unsupported-operation error returned by send().

WASI-P2: No decompression integration. The decompress.rs module compiles but is not called by WasiClient; no_decompression() is therefore already satisfied because the client does not add Accept-Encoding or decode response bodies. Users can apply the portable DecompressBody type manually.

Native runtimes integrate maybe_decompress() from decompress.rs into the response body pipeline. Each codec is behind a cfg feature: gzip, brotli, zstd, deflate.

HTTP/2 and HTTP/3

Featuretokiosmolcompiowasmwasi-p2
HTTP/2⚠ browser-managed [13]⚠ WASI-managed [14]
HTTP/2 config tuningtokiosmolcompio
HTTP/3http3⚠ browser-managed [13]⚠ WASI-managed [14]

[13] WASM: The browser negotiates HTTP/2 and HTTP/3 via ALPN. No version selection or tuning is exposed by the fetch API. The Http2Config type (http2.rs) is portable but its apply() method is gated behind #[cfg(not(target_arch = "wasm32"))]. Calling request-builder version() records an unsupported-operation error returned by send().

[14] WASI-P2: The WASI runtime negotiates the HTTP version. No version configuration is exposed. Http2Config is not applicable, and request-builder version() records an unsupported-operation error returned by send().

Native runtimes negotiate HTTP/2 through hyper’s http2 builder. Native HTTP/3 is Tokio-only and is enabled via the http3 feature, which requires rustls.

Why Features Are Platform-Managed on WASM and WASI-P2

WASM (host Fetch API)

The WASM client (wasm.rs) delegates networking to a compatible host’s Fetch API (web_sys::Request / globalThis.fetch()). Browser and worker runtimes share this transport entry point, while browser-specific policy still follows the browser Fetch implementation. This means:

  • TLS, DNS, HTTP/2, connection pooling: These are internal to the host’s network stack. The Fetch API provides no configuration hooks for any of these. The WasmClient literally cannot influence them.

  • Cookies and redirects: The browser processes Set-Cookie and follows redirects automatically as part of the fetch spec. Browser Fetch filters forbidden response headers (including Set-Cookie) from the Headers object, so Set-Cookie is not readable from WasmClient::headers() or CookieJar integration. The client has no way to suppress browser-level redirect following via the current implementation.

  • Timeout: There is no native timeout API in fetch. The WasmClient emulates timeouts using AbortController + setTimeout, which aborts the request after the configured duration. This is a combined request-level timeout — finer-grained connect/read timeouts are not available.

  • Compression: Browsers always send Accept-Encoding and transparently decompress responses. The client receives already-decompressed bytes. The decompress.rs module would be redundant if applied.

WASI-P2 (wasi:http/outgoing-handler)

The WASI-P2 client (wasi_p2.rs) uses the wasi:http/outgoing-handler interface. This interface is intentionally high-level:

  • TLS, DNS, connection pooling: The WASI component model abstracts these away behind wasi:http/outgoing-handler. Configuration depends entirely on the host runtime.

  • Redirects and cookies: outgoing-handler does not include redirect following or cookie management. These must be implemented in the client, but the current WasiClient has not yet wired in the portable RedirectPolicy or CookieJar types.

  • Timeout: Timeout values are passed through to the WASI runtime via RequestOptions. Whether they are honored depends on the runtime implementation.

  • Streaming response body: The WASI-P2 InputStream uses blocking_read, which is synchronous. This is consistent with the WASI Preview 2 model but means the WasiBodyStream does not support non-blocking iteration.

Native (tokio/smol/compio)

Native runtimes use aioduct’s full client engine (HttpEngineSend / HttpEngineLocal), which directly manages hyper connections, rustls TLS sessions, connection pools, DNS resolution, and HTTP redirect loops. All features listed above are available because the client owns the full networking stack.

Summary

Capability areaNative (tokio/smol/compio)WASM (host Fetch)WASI-P2
Request/response basicsFully supportedFully supportedFully supported
Streaming bodyFull async streamingResponse streaming via ReadableStreamSync-only body stream
Redirect, cookie, retry, middlewareIntegratedNot applicable / platform-managedTypes available, not integrated
TLS, DNS, pooling, HTTP versionConfigurableHost-managedWASI runtime-managed
ProxyFull supportNot availableNot available
CompressionPer-codec cfg featuresHost-managedNot integrated

Wasmtime Host Adapter

aioduct::WasiClient is the guest-side WASI-P2 client. It cannot and should not carry host trust policy such as allowed origins, CA roots, insecure certificate mode, secret header injection, body limits, or redacted diagnostics.

Hosts embedding Wasmtime components can enable the first-party wasmtime feature and use aioduct::wasmtime to install a wasi:http hook. That hook validates a guest request with host-owned policy, injects host-owned headers after validation, and forwards the request through a native aioduct transport.

The host transport line covers RuntimePoll native clients (TokioClient and SmolClient) directly. CompioClient is covered through CompioHostTransport, which owns the local-runtime worker and bounded body bridge needed for non-Send compio state. Browser wasm has no Wasmtime host hook; it remains browser Fetch managed.

This narrows the WASI-P2 host-side policy gap for Wasmtime embeddings. It does not change the guest aioduct::WasiClient API and it does not make browser wasm host-policy configurable.

Wasmtime Host Adapter

aioduct::wasmtime provides a host-side adapter for Wasmtime components that use WASI Preview 2 wasi:http. The guest keeps using aioduct::WasiClient. The host installs WasiHttpHost as the Wasmtime HTTP hook and forwards validated requests through a native aioduct transport.

This split is intentional. A guest component should not choose the trust model for a directory connector. The embedding host owns origin allow-lists, TLS roots, insecure test mode, secret header injection, request and response size limits, deadline budgets, and diagnostic redaction.

aioduct::wasmtime is behind the wasmtime feature. Enable it with the host runtime you want, such as tokio, smol, or compio, and enable one rustls provider when the host transport needs TLS:

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

Quick Start

The fastest local path is the runnable examples. They build the WASI Preview 2 guest demo, start a local HTTP server, install WasiHttpHost into Wasmtime, and show host policy forwarding through a native transport:

rustup target add wasm32-wasip2
cargo run -p example-wasmtime-host-tokio
cargo run -p example-wasmtime-host-smol
cargo run -p example-wasmtime-host-compio

Successful output includes the guest Status: 200 path, the expected error_for_status path, and host observations like:

host observations:
  GET /get HTTP/1.1 | authorization injected: yes
  POST /post HTTP/1.1 | authorization injected: yes
  GET /status/404 HTTP/1.1 | authorization injected: yes
host-owned secret header value was withheld from host output

The examples live under examples/wasmtime-host. They use local HTTP so they can be run without external network access. Pass a component path after -- if you want to run an already-built WASI command component instead of the bundled demo.

Shape

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

use aioduct::wasmtime::{ExactOriginPolicy, WasiHttpHost};
use http::header::{AUTHORIZATION, FORWARDED};
use http::HeaderValue;

fn build() -> Result<WasiHttpHost, Box<dyn std::error::Error>> {
let secret = HeaderValue::from_static("Bearer host-owned-token");
let deadline = Instant::now() + Duration::from_secs(5);

let hooks = WasiHttpHost::builder()
    .transport(
        aioduct::TokioClient::builder()
            .add_root_certificates_pem_bundle(include_bytes!("ca.pem"))?
            .build()?,
    )
    .policy(
        ExactOriginPolicy::new("https://directory.local:8443")?
            .forbid_sensitive_headers()
            .deny_headers([FORWARDED])
            .deny_header_prefixes(["x-forwarded-", "proxy-"])
            .inject_header(AUTHORIZATION, secret)
            .header_limit(16 * 1024)
            .body_limit(1024 * 1024)
            .deadline(deadline),
    )
    .build()?;
Ok(hooks)
}
}

The hooks become active when the Wasmtime host state exposes them through WasiHttpView and the component linker installs the WASI HTTP interfaces:

#![allow(unused)]
fn main() {
use aioduct::wasmtime::WasiHttpHost;
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::p2::bindings::Command as WasiCommand;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::WasiHttpCtx;
use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView};

struct HostState {
    table: ResourceTable,
    wasi: WasiCtx,
    http: WasiHttpCtx,
    hooks: WasiHttpHost,
}

impl WasiView for HostState {
    fn ctx(&mut self) -> WasiCtxView<'_> {
        WasiCtxView {
            ctx: &mut self.wasi,
            table: &mut self.table,
        }
    }
}

impl WasiHttpView for HostState {
    fn http(&mut self) -> WasiHttpCtxView<'_> {
        WasiHttpCtxView {
            ctx: &mut self.http,
            table: &mut self.table,
            hooks: &mut self.hooks,
        }
    }
}

async fn run(component_path: &std::path::Path, hooks: WasiHttpHost) -> Result<(), Box<dyn std::error::Error>> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, component_path)?;
let mut store = Store::new(
    &engine,
    HostState {
        table: ResourceTable::new(),
        wasi: WasiCtx::builder().build(),
        http: WasiHttpCtx::new(),
        hooks,
    },
);

let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
let command = WasiCommand::instantiate_async(&mut store, &component, &linker).await?;
command
    .wasi_cli_run()
    .call_run(&mut store)
    .await?
    .map_err(|()| std::io::Error::other("guest returned failure"))?;
Ok(())
}
}

The same hook can use a smol transport by building a SmolClient and passing it to .transport(...). The adapter can also use compio through CompioHostTransport, which starts a local-runtime worker. Use a builder factory so local-runtime connector slots are created on the worker thread:

#![allow(unused)]
fn main() {
use aioduct::wasmtime::{CompioHostTransport, ExactOriginPolicy, WasiHttpHost};

fn build() -> Result<WasiHttpHost, Box<dyn std::error::Error>> {
let hooks = WasiHttpHost::builder()
    .transport(CompioHostTransport::from_builder_factory(
        aioduct::CompioClient::builder,
    )?)
    .policy(ExactOriginPolicy::new("http://127.0.0.1:8080")?)
    .build()?;
Ok(hooks)
}
}

If the tokio feature is explicitly enabled, the builder creates a default Tokio transport when no explicit transport is supplied. With smol or compio, pass the host transport explicitly. The examples/wasmtime-host directory contains runnable Tokio, smol, and compio host examples.

Runtime Line

The adapter forwards through native HttpEngineSend<R, C> transports where R: RuntimePoll and C: ConnectorSend. That covers the current Send-capable native runtimes. Compio is supported through a separate local-runtime worker bridge because its HttpEngineLocal body and connection state are not Send:

Host transportSupported by aioduct::wasmtimeNotes
TokioClientYesExplicit tokio feature; can be default-built after feature selection
SmolClientYesExplicit smol feature and transport
CompioClientYesExplicit compio feature via CompioHostTransport

Browser wasm also does not have a host adapter. Browser WASM delegates networking to Fetch in the browser process; there is no Wasmtime host hook to install.

Policy Boundary

ExactOriginPolicy validates each outgoing WASI request before the native transport sees it:

  • the request origin must exactly match the configured scheme, host, and port
  • guest-supplied forbidden or sensitive headers can be rejected
  • host-specific guest header names and families, such as forwarded, x-forwarded-*, or proxy-*, can be denied before forwarding
  • host-owned headers are injected only after validation
  • injected header names are protected from guest override
  • request and response header section sizes can be capped
  • known and streaming request body sizes can be capped
  • response body size can be capped
  • an absolute host deadline can cap connect, first-byte, request-body write, response-body read, and total exchange time

Failures are mapped to WASI wasi:http ErrorCode values. Rejection observers receive low-cardinality RejectionReason values suitable for metrics and logs without including target URLs, header values, or secret material.

Denied header names and prefixes are also reported to Wasmtime as forbidden field names. Reserve them for host-owned metadata that guests should not set or depend on.

TLS Operator Config

Native transports keep TLS configuration in aioduct, not in the guest. For operator-provided CA bundles, prefer:

#![allow(unused)]
fn main() {
fn build() -> Result<aioduct::TokioClient, Box<dyn std::error::Error>> {
let client = aioduct::TokioClient::builder()
    .add_root_certificates_pem_bundle(include_bytes!("ca.pem"))?
    .build()?;
Ok(client)
}
}

This path rejects empty input, private keys, unsupported PEM sections, malformed PEM, and certificates rustls will not accept as trust roots. danger_accept_invalid_certs() remains available for test and development hosts, but should not be used for production connector policy.

Out Of Scope

The adapter is deliberately transport and policy infrastructure. It does not own directory-provider concepts, token-file loading, grant catalog semantics, component digest validation, or application-specific startup checks.

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.5", features = ["tokio", "rustls", "rustls-ring"] }

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

[dependencies]
aioduct = { version = "0.2.5", 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.5", 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.

Runtime Scope

RuntimeTLS providerConfiguration surface
TokiorustlsBuilder TLS methods and custom RustlsConnector
smolrustlsSame TLS configuration as Tokio
compiorustlsSame TLS configuration through the local client path
blockingWrapped native clientInherits the configured async client TLS behavior
wasmBrowser-managedCertificate verification, SNI, ALPN, and roots are controlled by the browser
wasi-p2Host-managedCertificate verification, SNI, ALPN, and roots are controlled by the WASI host

Native clients expose TLS version bounds, SNI enablement, extra root certificates, client identity, CRLs, hostname-verification bypass for tests, and a fully custom rustls configuration. Browser and WASI clients intentionally do not duplicate host-managed TLS policy knobs.

For operator-provided CA bundles, use the fallible PEM bundle path:

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

fn build_client() -> Result<TokioClient, Box<dyn std::error::Error>> {
let client = TokioClient::builder()
    .add_root_certificates_pem_bundle(include_bytes!("ca.pem"))?
    .build()?;
Ok(client)
}
}

This parser rejects empty bundles, private keys, unsupported PEM sections, malformed input, and certificates rustls will not accept as trust roots. It is the preferred path for host policy code that reads operator configuration.

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()?;
}

Encrypted ClientHello

Encrypted ClientHello (ECH) is available through rustls custom client configuration. Because ECH configuration is domain-specific and rustls forces TLS 1.3 when ECH is enabled, build the rustls::ClientConfig yourself and pass it to RustlsConnector::new.

This example uses rustls and webpki-roots APIs directly, so applications should declare those crates explicitly when building custom ECH configurations.

#![allow(unused)]
fn main() {
use std::sync::Arc;

use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;
use rustls::client::{EchConfig, EchMode};
use rustls::pki_types::EchConfigListBytes;

fn build_client(ech_config_list_bytes: Vec<u8>) -> Result<TokioClient, Box<dyn std::error::Error>> {
let hpke_suites = rustls::crypto::aws_lc_rs::hpke::ALL_SUPPORTED_SUITES;
let ech_config = EchConfig::new(
    EchConfigListBytes::from(ech_config_list_bytes),
    hpke_suites,
)?;
let root_store =
    rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let mut config = rustls::ClientConfig::builder_with_provider(provider)
    .with_ech(EchMode::Enable(ech_config))?
    .with_root_certificates(root_store)
    .with_no_client_auth();

config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];

let client = TokioClient::builder()
    .tls(RustlsConnector::new(Arc::new(config)))
    .build()?;
Ok(client)
}
}

The ech_config_list_bytes value comes from the ech parameter of the server’s DNS HTTPS record after base64 decoding. Use this path for Tokio, smol, compio, and blocking clients; they all share the same rustls connector.

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, protocol hint, proxy route, forced transport address, effective HTTP/3 endpoint) – for example, (https, api.example.com:443, Auto, direct, none, none). Two requests share a pooled connection only when all six fields match, except for the explicit h2/h3 coalescing path described below. The proxy route component keeps direct traffic separate from proxied traffic and separates different proxy configurations for the same origin. The forced-address component prevents a per-request transport override from satisfying an ordinary checkout or a request forced to a different address. The HTTP/3 endpoint component keeps connections to distinct Alt-Svc hosts or ports separate while preserving the origin authority used for SNI and request semantics.

Lifecycle

  1. Checkout: When a request is made, the pool checks for an existing idle connection to the target pool key. It uses LIFO ordering (most recently returned first) to prefer the freshest connections. Each candidate is checked for readiness and maximum lifetime — if a connection is stale, closed, too old, or saturated by the active stream cap, it’s skipped or discarded and the next one is tried.
  2. Reserve: If no reusable connection is available and pool_max_active_per_host is configured, a fresh connection attempt atomically reserves an active slot before DNS or TCP dialing. This prevents connection bursts from opening more concurrent fresh sockets than the configured cap. When the cap is reached, the request fails immediately with a typed PoolLimitKind::MaxActivePerHost error.
  3. Send: The request is sent on the connection (either reused or freshly established).
  4. Checkin: HTTP/2 and HTTP/3 connections return to the pool immediately because they can multiplex concurrent streams. A checkout’s active-stream permit is released after response headers arrive; successful HTTP/2 CONNECT requests retain it through the upgraded stream instead. HTTP/1.1 connections return only after the response body has drained and the sender is ready again. Connections past pool_max_lifetime are not checked back in. When the idle queue is 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.

Limits

pool_max_idle_per_host(n) controls how many idle handles are retained per pool key after requests complete. It does not limit the number of in-flight requests by itself.

pool_max_active_per_host(n) controls currently checked-out handles plus fresh connection attempts for the same pool key. Use it to cap concurrent sockets/handles toward one origin or proxy route. When the cap is reached, new requests fail immediately with a typed PoolLimitKind::MaxActivePerHost error. A value of 0 disables the active cap and leaves it unlimited.

pool_max_active_streams_per_connection(n) is different: it applies only to HTTP/2 and HTTP/3 multiplexed connections and caps how many concurrent sender handles may be checked out from one pooled transport. A permit starts when dispatch clones the pooled transport and normally ends when response headers arrive. Successful HTTP/2 CONNECT requests retain the permit through tunnel handoff. HTTP/1.1 has no multiplexed sender clones, so this limit does not affect HTTP/1.1.

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.

By default, aioduct does not cap active multiplexed checkouts per connection. Use pool_max_active_streams_per_connection(n) to limit how many HTTP/2 or HTTP/3 sender handles may be checked out from one pooled connection at a time. The value must be greater than 0. HTTP/1.1 connections are not affected.

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. TCP and QUIC candidates retain the same origin identity but are segregated by the full pool key, including protocol hint and any forced transport endpoint.

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_lifetime(Duration::from_secs(600)) // default: none
    .pool_max_idle_per_host(10)                  // default: 10
    .pool_max_active_per_host(64)                // default: unlimited
    .pool_max_active_streams_per_connection(100) // default: unlimited
    .build()?;
}

The builder methods compose fluently and are applied to the underlying ConnectionPool before the client is built.

Options

OptionDefaultDescription
pool_idle_timeout90sHow long an idle connection is kept before eviction
pool_max_lifetimenoneMaximum connection age before it stops being reused
pool_max_idle_per_host10Maximum idle connections per full pool key
pool_max_active_per_hostunlimitedMaximum checked-out handles and fresh connection attempts per pool key; 0 disables the cap
pool_max_active_streams_per_connectionunlimitedMaximum concurrent pooled H2/H3 sender checkouts per connection

Tokio, smol, compio, and blocking clients use this native pool. Wasm and wasi-p2 transports are platform-managed, so pooling and DNS reuse behavior are provided by the browser or WASI host rather than by ConnectionPool.

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.

Diagnostics

pool_stats() returns a PoolStats snapshot of the pool’s lifetime counters and current inventory. It is available on both HttpEngineSend and HttpEngineLocal (and therefore on the runtime client aliases). Counters are monotonic since engine creation and live in atomics outside the pool mutex, so reading them is cheap and never blocks request hot paths.

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

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

client.get("https://example.com/")?.send().await?;
client.get("https://example.com/")?.send().await?;

let stats = client.pool_stats();
println!("hits={} misses={}", stats.checkout_hits, stats.checkout_misses);
println!("idle={} active={}", stats.idle_pool_entries, stats.checked_out_pool_handles);

for host in &stats.hosts {
    println!(
        "{}://{} ({}, route={}): {} idle, {} active",
        host.scheme, host.authority, host.protocol_hint, host.route, host.idle, host.active,
    );
}
Ok(())
}
}

PoolStats fields

FieldTypeMeaning
checkout_hitsu64Checkouts that found an idle connection in the pool
checkout_coalesced_hitsu64Checkouts reused via SAN-based coalescing (always 0 on local engines)
checkout_missesu64Requests that exhausted all pool paths and required a fresh connection
stale_reuse_retriesu64Connections detected as stale mid-request and transparently retried
idle_timeout_evictionsu64Connections evicted due to idle timeout expiry
max_lifetime_evictionsu64Connections evicted due to exceeding their maximum lifetime
checkout_not_ready_evictionsu64Connections discarded at checkout because is_ready() returned false
capacity_evictionsu64Connections evicted because the per-host idle queue was at capacity
idle_pool_entriesusizeIdle pool handles across all hosts (current)
checked_out_pool_handlesusizeChecked-out pool handles across all hosts (current)
hostsVec<PoolHostStats>Per-host breakdown, sorted by (scheme, authority)

Each PoolHostStats carries scheme, authority, protocol_hint (Auto/H2c/AdaptiveH2c), route ("direct" or an opaque proxy-route label), and the host’s current idle / active handle counts.

Counts reflect pool-internal handle tracking, which can differ from physical connection counts for H2/H3 multiplexed transports — one transport may back several checked-out handles. checkout_coalesced_hits is always 0 on local engines because connection coalescing is a send-path feature.

The CLI surfaces these stats directly: aioduct http -v shows a pool summary, and aioduct download reports pool counters and inventory alongside its progress output.

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.

Request Dispatch Guarantees

aioduct’s native clients share one request-dispatch contract across direct requests, forwarded requests, pooled connections, retries, and proxies. This page defines when a request may be sent again, how the outbound protocol is selected, and which timeout owns connection establishment.

These guarantees apply to the native Tokio, smol, and compio clients. Blocking clients inherit them from the wrapped native client. Browser Fetch and wasi-p2 use platform-managed transports, so their pooling, proxy, and protocol retry decisions remain host-controlled.

Replay Safety

Body replayability and request replay eligibility are different properties:

  • An empty body can be reproduced without retaining bytes.
  • A buffered body can be reproduced from its stored bytes.
  • A streaming body is one-shot unless the transport returns the original, untouched request before serialization starts.

A complete request is dispatched again only when its method and retry policy permit another attempt, its body and aioduct-owned protocol metadata can be reproduced, and the transport provides sufficient processing evidence. Body replayability alone never authorizes a retry.

In particular:

  • A configured retry never replaces a consumed streaming body with an empty body.
  • A stale HTTP/1 connection that may have delivered a request does not cause an ambiguous non-idempotent request to be sent again.
  • HTTP/2 REFUSED_STREAM and qualifying GOAWAY boundaries may prove that a stream was not processed. HTTP/3 H3_REQUEST_REJECTED can provide equivalent evidence, but upstream h3 does not expose enough GOAWAY boundary state to authorize replay.
  • Replay preserves explicit aioduct-owned protocol metadata, including the protocol information needed by HTTP/2 extended CONNECT. Arbitrary user extensions are not cloned.

One-Shot Bodies and Pooling

A one-shot body may start on a ready pooled HTTP/1.1 or HTTP/2 connection. aioduct uses Hyper’s request-returning dispatch operation for this path. If the connection rejects the request before serialization, Hyper returns the exact request and aioduct may dispatch that same request once on a fresh connection.

Once serialization is accepted, a later write or connection failure does not make the body replayable. HTTP/3 does not expose equivalent request recovery, so one-shot HTTP/3 requests start on a fresh connection.

Forwarding Translation

Forwarding rewrites the upstream URI and runs on_request before final protocol classification. One final dispatch plan then controls all of the following:

  • origin-form versus full URI request targets;
  • request version accepted by the selected encoder;
  • exact or negotiable upstream protocol requirements;
  • pool identity, ALPN, and connection establishment;
  • ordinary requests, HTTP/1.1 upgrades, and HTTP/2 extended CONNECT;
  • protocol-aware request and response header cleanup.

HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 ingress can be translated to HTTP/1.1, HTTP/2, or HTTP/3 egress where the selected request mode is valid. An inbound version is never passed unchanged to an encoder that cannot represent it.

Hop-by-hop cleanup parses every Connection field value and removes every field it names. Successful upgrades preserve only their required connection fields. When HTTP/1.1 trailer negotiation applies, aioduct regenerates the canonical Connection: TE and TE: trailers fields; HTTP/1.0 egress strips TE. HTTP/2 and HTTP/3 egress may retain only canonical TE: trailers, but actual HTTP/3 trailer frames still fail closed as described in the HTTP/3 limitations. Other TE values remain invalid for HTTP/2 and HTTP/3.

Forwarded request bodies stay streaming. A real Request<hyper::body::Incoming> is not collected merely to support retries.

HTTP/3 Request Lifecycle

HTTP/3 sends request headers before consuming the complete body. The transport supports data frames followed by the request-stream FIN:

headers -> data* -> FIN

The upload and response directions are driven concurrently. An early final response does not itself stop the upload. After handing the response to the caller, aioduct continues the upload in a detached supervisor until the body and FIN complete, the peer sends STOP_SENDING, the request is canceled, or the upload fails. Producer stalls and QUIC flow-control stalls are separate timeout conditions: body polling uses the request write timeout, while send_data and finish require transport progress within that budget.

Request trailers are not sent with upstream h3. A trailer observed before response handoff fails the request with Error::Unsupported, including when the trailer and final response become ready together. A trailer emitted after response handoff still fails and cancels the detached upload, but it cannot retroactively replace the response already returned by send(). Response trailers fail with Error::Unsupported when the response body reaches them. Extended CONNECT, 0-RTT, and GOAWAY-based replay also remain fail-closed or deferred as described in the HTTP/3 limitations.

Proxy Route Consistency

Each wire dispatch attempt resolves one immutable proxy route before pool lookup. The snapshot includes the effective destination port, NO_PROXY decision, selected proxy or chain, resolved credentials, route identity, and protocol policy. The same snapshot controls pooled checkout, exact request recovery, fresh acquisition, and transparent stale fallback.

A configured retry, digest-auth retry, or redirect hop is a new wire attempt and resolves a new snapshot. This lets a selector or credential resolver react between attempts without allowing the pool key and actual route to disagree within one attempt.

Implicit destination ports participate in NO_PROXY matching: HTTP defaults to 80 and HTTPS defaults to 443. Explicit non-default ports remain distinct.

Proxy Establishment

HTTP and HTTPS proxy hops establish transparent CONNECT tunnels, including the final hop to a plain HTTP origin. HTTPS proxies negotiate HTTP/1.1 for the textual CONNECT exchange. Genuine HTTP/2 proxy CONNECT is a separate future transport and is not emulated by advertising H2 and writing HTTP/1.1 bytes.

Any successful 2xx CONNECT response establishes a tunnel. Informational, redirect, client-error, and server-error responses fail. CONNECT parsing is bounded and leaves bytes following the response header section available to the tunneled protocol.

One absolute connection-acquisition deadline begins on a pool miss before connection coordination. It covers:

  • waiting for another connection attempt and reserving pool capacity;
  • DNS resolution and TCP connection;
  • TLS to every HTTPS proxy;
  • CONNECT or SOCKS negotiation for every hop;
  • TLS to the origin inside the completed tunnel.

A transparent fallback from a stale pooled connection starts a new acquisition deadline when fresh acquisition begins. Request upload and response body timeouts remain separate from this connection deadline.

HTTP/3 proxy tunneling through CONNECT-UDP is not supported. A configured proxy uses the documented TCP transport fallback. Standard absolute-form HTTP forward-proxy dispatch is also separate from the transparent CONNECT mode.

Runtime Coverage

RuntimeDispatch coverage
TokioSend path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, HTTP/3, TLS, and proxies
smolShared Send path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, TLS, and proxies
compioLocal path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, TLS, and proxies
blockingGuarantees inherited from the wrapped native client
wasmBrowser-managed Fetch transport
wasi-p2Host-managed WASI HTTP transport

Every dispatch change requires focused policy tests and a representative real transport regression. Protocol tests assert exact origin receipt counts so a successful retry cannot hide a duplicate side effect.

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.

The runnable timeout-and-retry example contrasts replayable buffered request bodies with one-shot streaming bodies. Equivalent examples are available for smol and compio.

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 retryable HTTP statuses
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 — overall request, connect, response read-gap, and request upload write-gap timeouts
  • 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)
  • 408 Request Timeout and 425 Too Early — retried for idempotent requests (when retry_on_status is true)

Status-based retries are only attempted for idempotent methods: GET, HEAD, PUT, DELETE, OPTIONS, and TRACE. POST and PATCH are not retried on status by the default classifier. Other client errors are never retried. To disable all status-based retry, set retry_on_status(false).

Custom Classifier

For policies the built-in rules do not cover, attach a classifier with classify(). The closure receives a RetryContext describing the outcome (a response status or a transport error), the request method, and the attempt counters, and returns a RetryDecision:

  • RetryDecision::Retry — retry, still bounded by max_retries and any budget. This is an explicit opt-in, so it applies even to non-idempotent methods like POST.
  • RetryDecision::DoNotRetry — stop and return the response or error.
  • RetryDecision::UseDefault — defer to the built-in classification.
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RetryConfig, RetryDecision, RetryOutcome};

fn build() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
    .retry(RetryConfig::default().classify(|ctx| match ctx.outcome() {
        // Retry a normally-final 404 (e.g. eventually-consistent resource).
        RetryOutcome::Status(s) if s.as_u16() == 404 => RetryDecision::Retry,
        // Never retry 503 for this client.
        RetryOutcome::Status(s) if s.as_u16() == 503 => RetryDecision::DoNotRetry,
        // Everything else keeps the built-in behavior.
        _ => RetryDecision::UseDefault,
    }))
    .build()?;
let _ = client;
Ok(())
}
}

Returning UseDefault for every outcome leaves behavior identical to having no classifier. The classifier runs before the built-in rules on every attempt, for both status responses and transport errors.

Retry-After Header

When a server responds with a Retry-After header on a retryable status response (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-After is only considered after a retryable status response. Transport errors and timeout retries use the configured exponential backoff.

Policy Boundaries

Retries are scoped to the request builder that enabled the policy. A retryable final response after redirects causes the whole request operation to be tried again, including any redirect hops needed to reach the final target. Redirect limits and timeout limits still apply normally on every attempt.

The request timeout() is per attempt when retries are enabled. Backoff sleeps and later retry attempts can make total wall-clock time exceed that duration.

Streaming request bodies are not replayed after they have been consumed. Use a buffered body when a request must be safely replayable, or use a custom classifier only when the application can prove the operation is safe to repeat.

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. Headers whose HeaderValue is marked sensitive with set_sensitive(true) are stripped as well.

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. For cross-origin hops the Referer is reduced to the scheme and authority (no path). Following RFC 7231 §5.5.2, aioduct never sends Referer on an HTTPS→HTTP downgrade, so a secure source URL is not leaked into a plaintext request.

URL Fragments

Per RFC 7231 §7.1.2, aioduct preserves the URL fragment across redirects:

  • If the Location header carries its own fragment, that fragment wins.
  • If Location has no fragment, the original request’s fragment is inherited by the redirect target.

Fragments are not sent to servers (they are client-side per RFC 7230), so http::Uri strips them. aioduct tracks the effective fragment separately and exposes it on the final response via Response::fragment().

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.

Boundary and Subtype

By default, each Multipart value generates a safe multipart/form-data boundary. When interoperating with protocols that require a fixed boundary or a different multipart subtype, configure both explicitly:

#![allow(unused)]
fn main() {
use aioduct::Multipart;
fn build() -> Result<Multipart, aioduct::Error> {
let form = Multipart::new()
    .with_boundary("WebKitFormBoundaryABC123")?
    .subtype("mixed")?
    .text("metadata", "example");
Ok(form)
}
}

Custom boundaries are validated against the RFC 2046 character and length limits before a request is sent.

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(())
}

After the stream is exhausted, call trailers() to inspect any HTTP trailers captured from the response:

use aioduct::TokioClient;
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com/with-trailers")?.send().await?;
let mut stream = resp.into_bytes_stream();
while let Some(chunk) = stream.next().await {
    let _ = chunk?;
}

if let Some(trailers) = stream.trailers() {
    println!("trailers: {trailers:?}");
}
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.

Use write_timeout() to bound stalls while streaming request chunks. The timeout applies to gaps between upload chunks, not to DNS, TCP, TLS, response headers, or response body reads:

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

let client = TokioClient::builder()
    .write_timeout(Duration::from_secs(10))
    .build()?;
}

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 upstream h3 and quinn. The project deliberately does not maintain an h3 fork. Protocol behavior that aioduct cannot implement against the released upstream API without weakening validation or stream-lifecycle guarantees is deferred and fails closed where aioduct can identify it.

The Tokio-only http3-streaming-upload example demonstrates ordered upload chunks, producer write timeouts, and fail-closed request trailers against a local HTTP/3 server.

Feature Flag

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

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

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

[dependencies]
aioduct = { version = "0.2.5", 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. Reuse requires the full six-field pool key to match: scheme, authority, protocol hint, proxy route, any forced transport address, and the effective HTTP/3 endpoint. This keeps exact H3, ordinary TCP negotiation, proxied routes, and distinct Alt-Svc endpoints separate. 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)

Aioduct does not currently send HTTP/3 requests as 0-RTT early data. The h3_zero_rtt setter remains available for compatibility, but enabling it fails client construction with Error::Unsupported:

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

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

assert!(matches!(result, Err(aioduct::Error::Unsupported(_))));
Ok::<(), aioduct::Error>(())
}

This fails closed because a correct implementation must validate remembered peer SETTINGS and handle early-data rejection without weakening the request replay policy. Those guarantees are not available through the released upstream h3 API, and aioduct does not maintain an h3 fork. Leave 0-RTT disabled, which is the default.

Request Upload Lifecycle

HTTP/3 request uploads use data frames followed by the request-stream FIN:

headers -> data* -> FIN

Request upload and response receipt run concurrently. A final response does not implicitly cancel the upload. Once response headers are handed to the caller, aioduct supervises the remaining upload in a detached task until it completes, the peer sends STOP_SENDING, the request is canceled, or the upload fails.

If write_timeout expires before response handoff, send() returns Error::WriteTimeout. After response handoff, the response remains available; the timeout cancels the detached request send direction but cannot be surfaced through the already-completed send() future. Dropping the response before its body completes also cancels an unfinished upload.

Request trailers are not supported. If a trailer is observed before response handoff, send() returns Error::Unsupported even when a final response became ready on the same poll. If the body emits a trailer only after response handoff, the detached upload is failed and its send direction is canceled, but the already-completed send() future cannot be changed retroactively. Response trailers similarly produce Error::Unsupported when response-body consumption reaches them.

Deferred Protocol Capabilities

The following capabilities are intentionally deferred while aioduct uses the upstream h3 crate. Some require protocol state that upstream does not expose; others require additional aioduct lifecycle and validation work before they can be enabled safely:

  • Strict malformed-field handling — aioduct validates outgoing URI authority and Host consistency before calling upstream h3, and validates decoded regular fields it receives. Wire-level pseudo-field ordering, duplication, and malformed field-section metadata remain upstream-owned. Authority-free OPTIONS * forwarding fails closed because upstream cannot encode its pseudo-fields without inventing an authority.
  • Request and response trailers — upstream exposes trailer primitives, but aioduct does not yet provide the required end-to-end forwarding, validation, timeout, and cancellation guarantees. Request trailers observed before response handoff and response trailers reached while consuming the body return Error::Unsupported. A request trailer emitted by a detached upload after response handoff cannot be surfaced through the already-completed send() future; aioduct fails the upload and cancels its send direction instead.
  • Extended CONNECT — HTTP/3 CONNECT and forwarded HTTP/3 extended CONNECT metadata are rejected before opening a request stream.
  • 0-RTTh3_zero_rtt(true) is retained for API compatibility, but building that client returns Error::Unsupported. Validating remembered peer SETTINGS is required before this can be safe.
  • GOAWAY-based replay — upstream h3 reports RemoteClosing without exposing the validated GOAWAY stream-ID cutoff together with the affected request stream. Public APIs can exercise GOAWAY on the wire, but cannot supply the per-request boundary evidence needed to authorize replay. Connection- closing evidence without a specific protocol code therefore remains ambiguous and never authorizes transparent replay. Explicit H3_REQUEST_REJECTED stream errors may still prove that a reproducible request was not processed.

H3_VERSION_FALLBACK is distinct from ambiguous connection closure, but it does not prove that a non-idempotent operation had no application effect. In opportunistic Alt-Svc mode, aioduct permits that fallback once only for a reproducible idempotent request. Buffered POST requests and one-shot bodies remain terminal. The fallback consumes the same internal transport-recovery budget as other automatic recovery, and always-H3 mode remains terminal instead of silently changing protocol.

These boundaries avoid promising behavior that would require maintaining a private fork of the HTTP/3 protocol implementation.

Limitations

  • Experimental — the h3 ecosystem 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 transport — aioduct’s current Quinn transport requires the tokio feature and an active Tokio runtime when HTTP/3 is enabled. The generic RuntimePoll builder methods remain available for source compatibility, but enabling HTTP/3 on another runtime returns a setup error. Forwarded HTTP/3 requests without a configured QUIC endpoint are rejected before network I/O.
  • 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.

Host matching is case-insensitive. HstsStore also canonicalizes host inputs with a single port suffix, so storing Example.Com:443 matches later checks for example.com, example.com:80, and subdomains when includeSubDomains is present.

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();
}

Security Controls

aioduct keeps security-sensitive HTTP behavior explicit and feature-scoped. This page summarizes the built-in controls and the areas intentionally left to callers or host platforms.

Built-In Controls

AreaBehavior
HTTP cacheOpt-in HttpCache follows cacheable methods/statuses, Cache-Control, Expires, validators, Vary, stale-while-revalidate, and stale-if-error. Unsafe methods invalidate matching entries.
HSTSOpt-in HstsStore records Strict-Transport-Security only from HTTPS responses, upgrades later HTTP requests, handles includeSubDomains, max-age=0, case-insensitive host matching, and host inputs with a single port suffix.
Alt-Svc HTTP/3 upgradeOpt-in .alt_svc_h3(true) caches h3 advertisements by origin, respects ma, supports clear, and keeps the original request host for TLS SNI when the alternate service uses a different endpoint.
Header safetyRequest construction uses typed http header parsing and rejects invalid header names/values through builder errors. Cross-origin redirects strip built-in credential headers plus request headers whose values are marked sensitive.
HTTP Message Signaturesmessage_signatures builds RFC 9421 request and response signature bases, covers caller-supplied trailer fields with ;tr, formats Signature-Input / Signature headers for caller-provided signature bytes, parses and formats Accept-Signature, converts accepted signature requests into concrete signing configs, applies verification policy checks, verifies covered SHA-256 Content-Digest fields when body bytes are attached, can generate buffered request and bounded forward response SHA-256 Content-Digest fields, automatically signs finalized native request attempts, and can automatically sign forwarded downstream responses.
QUIC/TLS dependency lineHTTP/3 uses the workspace quinn dependency through the http3 and rustls feature set; dependency updates are handled through normal Cargo resolution and security review.
Proxy/environment settingsClient configuration is an immutable snapshot. Reusing a client keeps its configured proxy/cache/HSTS/Alt-Svc state; rebuild a client to re-read environment proxy variables or apply different proxy policy.

Request Signing

RFC 9421 request and response signature-base generation is available through MessageSignatureConfig. Callers choose the signing algorithm, sign the generated base, then attach the formatted Signature-Input and Signature headers manually on any runtime. Response bases can cover @status, caller-supplied trailer fields with ;tr, and related request components with ;req.

For incoming signed messages, MessageSignatureVerificationPolicy parses a selected request or response signature, enforces required covered components, accepted alg and keyid metadata, timestamp expiry, clock skew, and maximum signature age, verifies covered SHA-256 Content-Digest fields when body bytes are attached, then calls the caller-owned verifier with the rebuilt base and decoded signature bytes. Response verification can bind selected related request components with ;req; trailer components require caller-attached trailer maps. If a selected signature carries created or expires, the policy requires a configured validation time and fails closed without one. Callers still own cryptographic verification and trust decisions.

AcceptSignature parses and formats requested signature dictionaries, validates whether covered components target a request, response, or response with related request, and turns accepted entries into concrete MessageSignatureConfig values. Fulfillment remains explicit: callers own request selection, key selection, timestamp generation, cryptography, and attaching the resulting Signature-Input / Signature fields.

Native tokio, smol, and compio clients can also use HttpEngineBuilder::automatic_content_digest(true) to insert SHA-256 Content-Digest for buffered bodies, then HttpEngineBuilder::message_signature(config, signer) for sync signing, message_signature_async(config, signer) for send-runtime async signing, or message_signature_async_local(config, signer) for local-runtime async signing after default headers, cookies, cache validators, middleware, digest-auth retry headers, forwarding request rewrites, and framing cleanup have finalized each request attempt. Existing Content-Digest headers are preserved. Streaming and middleware-replaced bodies are not buffered automatically; set Content-Digest explicitly for those requests, using the SHA-256 value helpers when useful. Signer errors abort the request rather than sending an unsigned request.

Forward builders can generate a bounded downstream response Content-Digest and sign the downstream response with sync or async signers after upstream response hop-by-hop cleanup and on_response. Related-request components bind the inbound request snapshot, not the rewritten upstream request. Response finalization is fail-closed for signer errors, malformed existing signature dictionaries, unsupported trailer components, response digest bodies over the configured cap, CONNECT, known upgrade requests, and HTTP/1.1 101 Switching Protocols responses. Synthesized response digest fields are skipped for bodyless responses such as HEAD, 204, 205, and 304. Automatic trailer generation remains Future Work because request and response trailer transport semantics are not yet consistent across native HTTP/1, HTTP/2, HTTP/3, browser Fetch, WASI, and forwarding paths.

Platform-Managed Runtimes

Browser wasm and wasi-p2 transports delegate parts of caching, TLS verification, and network policy to the host. aioduct still applies API-level request construction and header validation where those runtimes expose the necessary controls.

HTTP Message Signatures

aioduct provides RFC 9421 HTTP Message Signatures helpers for request and response signature bases, parsed verification, Accept-Signature negotiation, covered Content-Digest verification, caller-supplied trailer field coverage with ;tr, native automatic request signing, native buffered request Content-Digest generation, bounded forward response Content-Digest generation, and forward-only automatic response signing. The portable helpers build signature bases, format and parse Signature-Input / Signature header values, turn accepted signature requests into concrete signing configs, apply verification policy checks, and expose the bytes callers pass to cryptographic code. Callers still choose the cryptographic signing and verification algorithms. Native clients can also insert SHA-256 Content-Digest for buffered request bodies, generate bounded downstream response digests for forwards, and run synchronous or asynchronous signers automatically for each finalized request attempt or for a forwarded downstream response.

Core Flow

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, HeaderValue, Method, Uri};

fn example() -> Result<(), Box<dyn std::error::Error>> {
let mut headers = HeaderMap::new();
headers.insert(http::header::DATE, HeaderValue::from_static("Tue, 20 Apr 2021 02:07:55 GMT"));

let target_uri: Uri = "https://example.com/foo?param=Value".parse()?;
let request_target: Uri = "/foo?param=Value".parse()?;

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::method())
    .component(MessageSignatureComponent::authority())
    .component(MessageSignatureComponent::path())
    .component(MessageSignatureComponent::header(http::header::DATE))
    .created(1_618_884_473)
    .key_id("test-key");

let base = config.signature_base(&Method::GET, &target_uri, &request_target, &headers)?;
let signature_bytes = my_signing_function(base.as_bytes());
let signature_headers = config.headers_from_signature(signature_bytes)?;
signature_headers.insert_into(&mut headers)?;
Ok(())
}
fn my_signing_function(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

target_uri is the full URI for derived components such as @scheme, @authority, @target-uri, @path, and @query. request_target is the actual request URI form that will be sent on the wire and is used for @request-target. This distinction matters for forwarding and CONNECT-style requests.

Use MessageSignatureRequestContext::with_trailers(...) or MessageSignatureResponseContext::with_trailers(...) with the *_for_context() helpers when a signature covers a trailer field with ;tr. aioduct reads those values only from the attached trailer map; header fields with the same name are signed separately, matching RFC 9421. Trailer components can also use ;sf, ;key, ;bs, and response related-request ;req where those parameters are otherwise valid.

Supported Components

ComponentSource
@methodRequest method, preserving case.
@schemeLowercase target URI scheme.
@authorityTarget URI authority with lowercase host and default http:80 / https:443 ports omitted.
@request-targetThe actual final request URI form.
@target-uriThe full target URI.
@pathTarget URI path, with an empty path normalized to /.
@queryTarget URI query with a leading ?; absent query signs as ?.
@statusResponse status code with no reason phrase.
Header and trailer fieldsLowercase field names; repeated values are joined with , . Supports ;sf, ;key, ;bs, and caller-supplied ;tr component parameters.

When building a response signature base with a related request, MessageSignatureComponent::related_request() adds the ;req parameter and derives that component from the triggering request.

Structured Field Components

RFC 9421 ;sf components need the field’s RFC 9651 top-level type. When signing, use structured_dictionary(), structured_list(), or structured_item() on the covered header/trailer component:

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

fn example() -> Result<(), Box<dyn std::error::Error>> {
let priority = http::header::HeaderName::from_static("priority");
let accept_ch = http::header::HeaderName::from_static("accept-ch");

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::header(priority).structured_dictionary())
    .component(MessageSignatureComponent::header(accept_ch).structured_list());
let _ = config;
Ok(())
}
}

Parsed Signature-Input values only contain the ;sf flag, not the Dictionary/List/Item type. Configure that local type before rebuilding or verifying a parsed base:

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignature, MessageSignatureStructuredFieldType,
    MessageSignatureVerificationPolicy,
};

fn example(signature: MessageSignature) {
let priority = http::header::HeaderName::from_static("priority");

let signature = signature.with_structured_field_type(
    priority.clone(),
    MessageSignatureStructuredFieldType::Dictionary,
);

let policy = MessageSignatureVerificationPolicy::new()
    .structured_field_type(priority, MessageSignatureStructuredFieldType::Dictionary);
let _ = (signature, policy);
}
}

If a parsed ;sf component has no configured type, rebuilding the signature base fails with MessageSignatureError::UnknownStructuredFieldType. ;key components already identify Dictionary Structured Field members and do not need a separate type argument.

Missing covered headers, duplicate component identifiers, invalid labels, non-ASCII generated signature bases, and covered header values that cannot be represented as ASCII header fields return MessageSignatureError.

Native Automatic Signing

Native tokio, smol, and compio clients can sign requests automatically with HttpEngineBuilder::message_signature(config, signer) for synchronous signers, message_signature_async(config, signer) for send-runtime async signers, or message_signature_async_local(config, signer) for local-runtime async signing futures. The signer runs after default headers, cookies, cache validators, middleware, digest-auth retry headers, forwarding request rewrites, and request framing cleanup have finalized each native dispatch attempt. Stale pooled-connection replays are re-signed before retrying.

#![allow(unused)]
fn main() {
use aioduct::{HttpEngineSend, MessageSignatureComponent, MessageSignatureConfig};
use aioduct::runtime::TokioRuntime;
use aioduct::runtime::tokio_rt::TcpConnector;

fn example() -> Result<(), Box<dyn std::error::Error>> {
let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::method())
    .component(MessageSignatureComponent::authority())
    .component(MessageSignatureComponent::path())
    .key_id("test-key");

let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
    .message_signature(config, |base: &[u8]| {
        Ok(sign_with_your_key(base))
    })
    .build()?;
let _ = client;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Async automatic signers receive an owned MessageSignatureBase, so request and header borrows do not cross the signer await boundary:

#![allow(unused)]
fn main() {
use aioduct::{HttpEngineSend, MessageSignatureBase, MessageSignatureComponent, MessageSignatureConfig};
use aioduct::runtime::TokioRuntime;
use aioduct::runtime::tokio_rt::TcpConnector;

async fn example() -> Result<(), Box<dyn std::error::Error>> {
let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::method())
    .component(MessageSignatureComponent::authority())
    .key_id("test-key");

let client = HttpEngineSend::<TokioRuntime, TcpConnector>::builder()
    .message_signature_async(config, |base: MessageSignatureBase| async move {
        Ok(sign_with_remote_key(base.as_bytes()).await)
    })
    .build()?;
let _ = client;
Ok(())
}
async fn sign_with_remote_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

When automatic signing is configured, aioduct owns its configured signature label in the Signature-Input and Signature request fields. It preserves unrelated labels and replaces the configured label on every signed attempt. If the signer fails, the request is not dispatched.

Forwarded requests are signed after hop-by-hop cleanup, upstream URI rewriting, explicit header forwarding/removal, and on_request hooks. Components derived from the target URI use the upstream URI; @request-target uses the final URI form sent on the wire.

Forward builders can also sign the response returned downstream with response_message_signature(...), response_message_signature_async(...), or response_message_signature_async_local(...). Response signing runs after upstream response hop-by-hop headers are stripped and after on_response runs, then strips hop-by-hop headers again before generating the base. Related-request components use the inbound request snapshot, not the rewritten upstream request. For origin-form inbound requests, set downstream_target_uri(...) when the response signature covers related-request @scheme, @authority, or @target-uri. Automatic response signing rejects CONNECT, known upgrade requests, HTTP/1.1 101 Switching Protocols responses, and trailer components. Use response_content_digest(max_bytes) to buffer a forwarded response up to a fixed cap and insert Content-Digest before response signing, allowing the signature to cover content-digest without unbounded buffering. Existing digest fields are preserved. Bodyless responses such as HEAD, 204, 205, and 304 are not assigned synthesized digest fields.

Automatic Content-Digest

Native clients can opt in to SHA-256 Content-Digest generation with HttpEngineBuilder::automatic_content_digest(true) or override it per request with RequestBuilderSend::automatic_content_digest(...) / RequestBuilderLocal::automatic_content_digest(...). When enabled, aioduct inserts Content-Digest: sha-256=:...: for buffered request bodies that do not already have a Content-Digest header. Requests without a configured body are left unchanged; use an explicitly empty buffered body to sign an empty-body digest.

Digest insertion happens after middleware and framing-header cleanup and before automatic message signing. A signature that covers content-digest therefore covers the generated value. If a request already has Content-Digest, aioduct preserves it and signs that caller-supplied value.

aioduct does not auto-buffer streaming bodies and does not generate digest or signature trailers. Streaming bodies and middleware-replaced bodies must provide an explicit Content-Digest header when automatic digest generation is enabled. Use sha256_content_digest_value(...) when the complete body is already in memory, or sha256_content_digest_value_from_digest(...) when a streaming caller has precomputed the 32-byte SHA-256 digest out-of-band.

#![allow(unused)]
fn main() {
use aioduct::{CONTENT_DIGEST, sha256_content_digest_value_from_digest};
use http::{HeaderMap, HeaderName};

fn example() -> Result<(), Box<dyn std::error::Error>> {
let mut headers = HeaderMap::new();
let digest = precomputed_stream_digest();
headers.insert(
    HeaderName::from_static(CONTENT_DIGEST),
    sha256_content_digest_value_from_digest(digest)?,
);
Ok(())
}
fn precomputed_stream_digest() -> [u8; 32] { [0_u8; 32] }
}

Manual And Async Signers

For async, host-backed, WebCrypto, KMS, or HSM signing, build the signature base, await the external signer yourself, then call headers_from_signature() with the returned bytes. This avoids blocking a runtime thread.

The synchronous MessageSignatureSigner trait is used by native automatic signing and local CPU-bound signing. Do not use a blocking network or device call inside that synchronous signer on an async runtime thread.

Response Signature Bases

Use response_signature_base() for response-only signatures and request_response_signature_base() when the response signature covers parts of the related request with ;req. Use the *_for_context() variants when the covered components include caller-supplied trailer fields with ;tr.

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};

fn example() -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/foo?param=Value".parse()?;
let request_target: Uri = "/foo?param=Value".parse()?;
let mut request_headers = HeaderMap::new();
request_headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("application/json"));

let mut response_headers = HeaderMap::new();
response_headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"));

let config = MessageSignatureConfig::new("reqres")?
    .component(MessageSignatureComponent::status())
    .component(MessageSignatureComponent::header(http::header::CONTENT_TYPE))
    .component(MessageSignatureComponent::method().related_request())
    .component(MessageSignatureComponent::path().related_request())
    .created(1_618_884_479)
    .key_id("test-key");

let base = config.request_response_signature_base(
    &Method::POST,
    &target_uri,
    &request_target,
    &request_headers,
    StatusCode::SERVICE_UNAVAILABLE,
    &response_headers,
)?;
let signature_headers = config.headers_from_signature(my_signing_function(base.as_bytes()))?;
let _ = signature_headers;
Ok(())
}
fn my_signing_function(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

sign_response() provides the same synchronous signer callback pattern as sign_request() for response-only bases. For response bases that also cover a related request, sign request_response_signature_base() output and pass the signature bytes to headers_from_signature(). Native automatic response signing is available on forward builders only. Forward builders can also generate a bounded response Content-Digest before signing; HttpEngineBuilder::message_signature() continues to configure request signing.

Accept-Signature

Use AcceptSignature to parse or build RFC 9421 Accept-Signature dictionaries. Each AcceptSignatureEntry names the requested output signature label, the covered components, and requested metadata such as created, expires, alg, keyid, nonce, and tag.

#![allow(unused)]
fn main() {
use aioduct::{AcceptSignature, AcceptSignatureEntry, MessageSignatureComponent};
use http::HeaderMap;

fn example(mut headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let accept = AcceptSignature::new().entry(
    AcceptSignatureEntry::new("sig1")?
        .component(MessageSignatureComponent::status())
        .component(MessageSignatureComponent::method().related_request())
        .created()
        .key_id("test-key"),
);

accept.validate_request_response_target()?;
accept.insert_into(&mut headers)?;
Ok(())
}
}

Use validate_request_target() when an Accept-Signature response asks the client to sign its next request. Use validate_request_response_target() when an Accept-Signature request asks the server to sign the response and that response signature can cover related request components with ;req.

AcceptSignatureFulfillment provides concrete metadata values, such as generated created and expires timestamps. The *_signature_config() helpers validate target-message applicability, copy requested components and metadata into a MessageSignatureConfig, and fail closed when a requested timestamp is missing or a supplied metadata value conflicts with the request.

#![allow(unused)]
fn main() {
use aioduct::{AcceptSignature, AcceptSignatureFulfillment};
use http::{HeaderMap, Method, StatusCode, Uri};

fn example(request_headers: HeaderMap, mut response_headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let accept = AcceptSignature::from_headers(&request_headers)?;
let fulfillment = AcceptSignatureFulfillment::new()
    .created(1_618_884_500)
    .key_id("test-key");

let target_uri: Uri = "https://example.com/foo".parse()?;
let request_target: Uri = "/foo".parse()?;

for config in accept.request_response_signature_configs(&fulfillment)? {
    let base = config.request_response_signature_base(
        &Method::GET,
        &target_uri,
        &request_target,
        &request_headers,
        StatusCode::OK,
        &response_headers,
    )?;
    let signature = my_signing_function(base.as_bytes());
    config.headers_from_signature(signature)?.insert_into(&mut response_headers)?;
}
Ok(())
}
fn my_signing_function(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Fulfillment remains explicit: callers still choose which requests to honor, select signing keys, generate timestamps, run cryptography, and attach the resulting Signature-Input / Signature fields. Receivers can ignore an unacceptable request by selecting individual AcceptSignatureEntry values instead of fulfilling the whole dictionary.

Request Verification

MessageSignature::from_headers(&headers, "sig1") parses existing Signature-Input and Signature fields, selects one label, exposes known metadata parameters such as created, expires, alg, and keyid, and returns the decoded signature bytes. It rejects malformed dictionaries, duplicate labels, mismatched labels, and unknown selected labels.

The parsed value can rebuild the request signature base for fully manual caller-owned crypto verification:

#![allow(unused)]
fn main() {
use aioduct::MessageSignature;
use http::{HeaderMap, Method, Uri};

fn example(headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/foo?param=Value".parse()?;
let request_target: Uri = "/foo?param=Value".parse()?;
let parsed = MessageSignature::from_headers(&headers, "sig1")?;
let base = parsed.signature_base(&Method::GET, &target_uri, &request_target, &headers)?;

verify_with_your_key(base.as_bytes(), parsed.signature(), parsed.params());
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8], _: &aioduct::MessageSignatureParams) {}
}

For common verification policy checks, use MessageSignatureVerificationPolicy. The policy parses a selected label, requires covered components, filters accepted alg and keyid metadata, checks created / expires timestamps with optional clock skew and maximum age, then calls your verifier with the selected label, parsed params, rebuilt base bytes, and decoded signature bytes.

When body bytes are available, attach them to the request or response context with with_body(...). If the selected signature covers content-digest, the policy verifies a SHA-256 Content-Digest field before rebuilding the signature base and before invoking your verifier. For response signatures that cover a related request field with ;req, attach the related request body to MessageSignatureRequestContext. If no body bytes are attached, verification preserves the previous signature-only behavior and does not check the digest field. Malformed digest fields, digest fields without sha-256, and mismatched body bytes fail closed with MessageSignatureError before your verifier runs. Attach trailer maps with with_trailers(...) when the selected signature covers trailer fields with ;tr; without an attached trailer map, those covered components fail closed before the verifier runs.

When the selected signature carries created or expires, configure validation_time() so the policy can validate those timestamps. Without a validation time, verification fails closed with MissingValidationTime.

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignatureComponent, MessageSignatureVerificationInput,
    MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, Uri};

fn example(headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/foo?param=Value".parse()?;
let request_target: Uri = "/foo?param=Value".parse()?;

let policy = MessageSignatureVerificationPolicy::new()
    .required_component(MessageSignatureComponent::method())
    .required_component(MessageSignatureComponent::authority())
    .accepted_algorithm("ed25519")
    .accepted_key_id("test-key")
    .validation_time(1_618_884_500)
    .max_age(300)
    .clock_skew(5);

policy.verify_request(
    &headers,
    "sig1",
    &Method::GET,
    &target_uri,
    &request_target,
    &|input: MessageSignatureVerificationInput<'_>| {
        Ok(verify_with_your_key(
            input.params(),
            input.signature_base(),
            input.signature(),
        ))
    },
)?;
Ok(())
}
fn verify_with_your_key(
    _: &aioduct::MessageSignatureParams,
    _: &[u8],
    _: &[u8],
) -> bool {
    true
}
}

For request body integrity, use the request context form:

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignatureRequestContext, MessageSignatureVerificationInput,
    MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, Uri};

fn example(headers: HeaderMap, body: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/foo".parse()?;
let request_target: Uri = "/foo".parse()?;
let request = MessageSignatureRequestContext::new(
    &Method::POST,
    &target_uri,
    &request_target,
    &headers,
)
.with_body(body);

MessageSignatureVerificationPolicy::new().verify_request_context(
    request,
    "sig1",
    &|input: MessageSignatureVerificationInput<'_>| {
        Ok(verify_with_your_key(input.signature_base(), input.signature()))
    },
)?;
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8]) -> bool { true }
}

MessageSignature::verify_request() applies the same policy to an already parsed request signature. verify_request_context() is the parsed-signature equivalent for body-aware request verification. verify_response() verifies response-only signatures, and verify_request_response() verifies response signatures that bind selected components from the originating request with ;req.

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignatureComponent, MessageSignatureRequestContext,
    MessageSignatureResponseContext, MessageSignatureVerificationInput,
    MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, StatusCode, Uri};

fn example(request_headers: HeaderMap, response_headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/foo?param=Value".parse()?;
let request_target: Uri = "/foo?param=Value".parse()?;
let request = MessageSignatureRequestContext::new(
    &Method::POST,
    &target_uri,
    &request_target,
    &request_headers,
);
let response = MessageSignatureResponseContext::new(StatusCode::OK, &response_headers);

let policy = MessageSignatureVerificationPolicy::new()
    .required_component(MessageSignatureComponent::status())
    .required_component(MessageSignatureComponent::method().related_request())
    .accepted_key_id("test-key")
    .validation_time(1_618_884_500);

policy.verify_request_response(
    request,
    response,
    "sig1",
    &|input: MessageSignatureVerificationInput<'_>| {
        Ok(verify_with_your_key(
            input.params(),
            input.signature_base(),
            input.signature(),
        ))
    },
)?;
Ok(())
}
fn verify_with_your_key(
    _: &aioduct::MessageSignatureParams,
    _: &[u8],
    _: &[u8],
) -> bool {
    true
}
}

Header Ownership

When automatic signing is not configured, user-supplied Signature and Signature-Input headers are ordinary headers and are preserved. Native automatic request and forward-response signing own their configured label in those two fields when configured: they replace that label on each signed message while preserving unrelated labels, so redirects, retries, digest-auth retries, forwarding rewrites, stale connection replays, and forwarded response mutations cannot send signatures for an earlier message shape.

Runtime Coverage

The helpers are portable and can be used with native, blocking, wasm, and wasi-p2 request builders by inserting the generated headers manually. Caller-supplied trailer maps for ;tr components are portable context inputs, not automatic trailer generation. Native automatic request signing supports synchronous and asynchronous signers for tokio, smol, and compio request dispatch. Forward-only automatic response signing supports synchronous signers, send-runtime async signers for tokio/smol, and local async signers for compio. Buffered automatic Content-Digest generation is available for native request bodies, and bounded forward response Content-Digest generation is available on native forward builders. Blocking clients inherit configured native-client behavior.

Automatic trailer-based digest or signature generation is intentionally not exposed yet. HTTP/1 and HTTP/2 native dispatch can carry body trailer frames, but the current native HTTP/3 dispatch streams request data and fails closed on request and response trailer frames. Browser Fetch and WASI do not expose matching request-trailer hooks, and forward response signing builds the signature headers before the downstream response body is streamed. Until those transport seams have common semantics, use explicit Content-Digest fields and caller-supplied trailer maps with the manual context APIs.

Browser Fetch and WASI hosts can still alter or reject some headers at the host boundary. That host behavior is outside aioduct’s control.

RFC 9421 Conformance Matrix

This matrix tracks RFC 9421 example coverage against the current public API. Rows marked supported are covered by normal Rust tests, not ignored or expected-failing tests. Rows marked planned remain visible here until their owner work lands.

RFC 9421 areaStatusTest coverageOwnerNotes
Appendix B.2.3 full request coverageSupportedappendix_b23_full_coverage_request_baseCurrentCovers request derived components, plain fields, Content-Digest as a caller-supplied header, and signature parameters.
Appendix B.2.5 HMAC request exampleSupportedappendix_b25_hmac_request_base_and_header_formattingCurrentTests request base and single-label header formatting with caller-supplied signature bytes. It does not test HMAC itself.
Appendix B.2.6 Ed25519 request exampleSupportedappendix_b26_ed25519_request_baseCurrentTests request base only; Ed25519 signing remains caller-owned.
Appendix B.3 TLS-terminating proxy request baseSupportedappendix_b3_tls_terminating_proxy_request_baseCurrentCovers proxy-style authority and a long Client-Cert field value.
Appendix B.4 request transformationsSupportedappendix_b4_safe_request_transformations_keep_base_stable, appendix_b4_unsafe_request_transformations_change_baseCurrentCovers stable base strings across safe transformations and changed base strings for covered method/authority or reordered same-name fields.
Appendix B.2.1 empty covered component setSupportedempty_covered_component_set_builds_signature_params_only_base, parsed_signature_accepts_empty_covered_set, verification_policy_allows_empty_covered_component_setCurrentSupports Signature-Input values like sig1=();... and builds a signature base containing only @signature-params. The RFC discourages empty sets; verifiers can require concrete components with policy.
Appendix B.2.2 @query-paramSupportedappendix_b22_query_param_request_baseCurrentCovers named query parameter parsing, form-style decoding, percent-encoded component identifiers, and missing/duplicate parameter errors.
Component parameter ;bsSupportedbyte_sequence_header_values_are_signed_as_structured_field_listCurrentCovers Byte Sequence wrapping for caller-supplied header field values.
Component parameter ;keySupporteddictionary_key_header_values_are_signed_as_structured_field_members, dictionary_key_missing_malformed_and_duplicate_valuesCurrentCovers Dictionary Structured Field member selection, strict member serialization, missing key errors, malformed dictionary errors, and duplicate source keys using the RFC 9651 last-value rule.
Component parameter ;sfSupportedstructured_field_header_values_are_signed_with_strict_serialization, parsed_signature_requires_structured_field_type_for_sf_components, verification_policy_applies_required_structured_field_typeCurrentCovers strict serialization for valid RFC 9651 Dictionary, List, and Item field values. Parsed ;sf components require caller-supplied type metadata because the wire parameter does not identify the top-level Structured Field type.
Component parameter ;trSupportedresponse_context_uses_caller_supplied_trailer_fields, trailer_fields_are_distinct_from_headers_and_support_field_parameters, trailer_components_require_attached_trailer_fields, verification_policy_calls_verifier_with_trailer_componentsCurrentCovers caller-supplied request and response trailer fields, keeps same-name header and trailer fields separate, composes with ;sf, ;key, ;bs, and related request ;req. Automatic trailer generation remains future work.
Response @status and response signature basesSupportedbuilds_response_signature_base_for_status_and_headers, section_24_response_with_related_request_base, sign_response_uses_signer_callback, forward_response_signature_covers_response_hook_and_strips_hop_by_hop, test_compio_forward_response_message_signatureCurrentBuilds response signature bases, formats response signature headers from caller-supplied signature bytes, and can automatically sign forwarded downstream responses on native send/local runtimes.
Related request components ;reqSupportedrequest_response_signature_base_uses_related_request_components, parsed_signature_rebuilds_response_and_related_request_base, response_signature_rejects_components_from_wrong_contextCurrentRoutes ;req components to the related request when building response bases and rejects ;req on request targets or without related request context.
Multiple signature dictionariesSupportedinsert_into_merges_signature_headers_by_label, automatic_signing_merges_existing_signature_headers_by_labelCurrentGenerated signatures parse existing Signature-Input and Signature dictionaries, reject duplicate or mismatched labels, preserve unrelated labels, and replace only the configured label.
Parsed signature selection and request-base rebuildSupportedparsed_signature_selects_label_and_rebuilds_request_base, parsed_signature_handles_component_parameters, parsed_signature_requires_structured_field_type_for_sf_components, parsed_signature_reports_selection_and_header_errorsCurrentParses selected Signature-Input / Signature labels, exposes known metadata and signature bytes, preserves extension metadata in the rebuilt base, applies caller-provided ;sf type metadata, and rejects malformed or mismatched fields.
Message verification policy APISupportedverification_policy_calls_verifier_with_rebuilt_base, verification_policy_calls_verifier_with_response_base, verification_policy_calls_verifier_with_related_request_response_base, parsed_response_signature_can_verify_with_policy, verification_policy_applies_required_structured_field_type, verification_policy_reports_selection_and_header_errors, verification_policy_rejects_unacceptable_signature_metadata, verification_policy_rejects_failed_verifier_callbackCurrentApplies required-component, accepted-algorithm, accepted-key-id, timestamp, max-age, parsed ;sf type metadata, and verifier-callback checks for selected request, response, and request-response signatures. Cryptographic verification remains caller-owned.
Covered Content-Digest verificationSupportedverification_policy_checks_request_content_digest_before_signature, verification_policy_rejects_mismatched_request_content_digest_before_signature, verification_policy_rejects_malformed_and_unsupported_content_digest_before_signature, verification_policy_checks_response_content_digest_before_signature, verification_policy_checks_related_request_content_digest_before_signature, verification_policy_skips_content_digest_check_when_body_is_unavailableCurrentVerifies SHA-256 Content-Digest before caller-owned signature verification when body bytes are attached and the selected signature covers the whole content-digest field or its sha-256 dictionary member, including related request fields with ;req.
Accept-Signature parser and builderSupportedaccept_signature_parses_rfc_style_request, accept_signature_formats_and_inserts_header, accept_signature_from_headers_combines_field_values, accept_signature_reports_header_errors, accept_signature_validates_target_message_componentsCurrentParses and formats requested signature dictionaries, exposes requested metadata, and validates request, response, or request-response target component applicability.
Accept-Signature fulfillment helpersSupportedaccept_signature_fulfills_response_with_related_request, accept_signature_fulfills_next_request, accept_signature_fulfillment_reports_unfulfillable_requests, accept_signature_allows_ignoring_requests_and_adding_signaturesCurrentConverts accepted entries into concrete MessageSignatureConfig values, fills requested metadata, rejects missing or conflicting requested parameters, supports caller-selected ignored requests, and allows additional signatures. Cryptography and header attachment remain caller-owned.
SHA-256 Content-Digest value helpersSupportedformats_sha256_content_digest, formats_precomputed_sha256_content_digest, inserts_sha256_content_digestCurrentBuilds explicit Content-Digest field values from complete body bytes or a precomputed 32-byte SHA-256 digest.
Buffered automatic Content-Digest generationSupportedautomatic_content_digest_is_inserted_before_signing, automatic_content_digest_preserves_manual_header, automatic_content_digest_rejects_streaming_body_without_manual_digest, automatic_content_digest_rejects_middleware_replaced_body_without_manual_digestCurrentNative dispatch can insert SHA-256 Content-Digest for buffered bodies before automatic signing. Existing digest fields are preserved; streaming or middleware-replaced bodies need explicit digest fields.
Bounded forward response Content-Digest generationSupportedforward_response_content_digest_is_signed_and_preserves_body, forward_response_content_digest_rejects_body_over_limit, forward_response_content_digest_rejects_connect_before_upstream, forward_response_content_digest_preserves_existing_field, forward_response_content_digest_skips_head_response, forward_response_content_digest_skips_not_modified_response, test_compio_forward_response_content_digest_is_signed, test_compio_forward_response_content_digest_skips_not_modified_responseCurrentNative forward builders can buffer downstream response bodies up to a caller cap, insert SHA-256 Content-Digest before response signing, preserve existing digest fields, skip synthesized digests for bodyless responses, and fail closed over the cap.
Async automatic signingSupportedasync_automatic_signing_adds_headers_after_middleware, async_signer_error_aborts_request_before_dispatch, test_compio_async_local_message_signatureCurrentSend-runtime signing uses message_signature_async with a Send future; local-runtime signing uses message_signature_async_local and can await a non-Send future. Sync automatic signing remains supported.
Automatic trailer-based digest/signature generationFuture follow-upMatrix onlyPost first passTrailer fields are standards-valid, but automatic trailer generation needs common request and response trailer semantics across native HTTP/1, HTTP/2, HTTP/3, browser Fetch, WASI, and forwarding paths first.
Cryptographic algorithm validationNot in scopeMatrix onlyCaller-ownedaioduct builds bases and header values; callers own keys, algorithms, signing, and verification cryptography.

Future Work

  • Automatic trailer-based digest/signature generation after request and response trailer semantics are proven across HTTP/1, HTTP/2, HTTP/3, browser Fetch, WASI, and forwarding paths.

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.5", 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, HTTPS, SOCKS4/SOCKS4a, SOCKS5, and SOCKS5h proxies. Both HTTP and HTTPS targets use a CONNECT tunnel through HTTP and HTTPS proxies. SOCKS proxies tunnel all traffic regardless of scheme at the TCP level.

Runnable Examples

ScenarioTokiosmolcompio
One HTTP, HTTPS, or SOCKS proxyproxy-connectproxy-connectproxy-connect
Two-hop proxy chainproxy-chainproxy-chainproxy-chain
Scheme routing, NO_PROXY, and environment credentialsproxy-routingproxy-routingproxy-routing

Proxy Schemes

SchemeConstructorDNS ResolutionDescription
http://ProxyConfig::http()N/AHTTP CONNECT proxy
https://ProxyConfig::https()N/ATLS-wrapped HTTP CONNECT proxy
socks4://ProxyConfig::socks4()LocalSOCKS4 proxy
socks4a://ProxyConfig::socks4()RemoteSOCKS4a proxy (domain sent to proxy)
socks5://ProxyConfig::socks5()LocalSOCKS5 proxy (client resolves hostnames, sends IP)
socks5h://ProxyConfig::socks5h()RemoteSOCKS5h proxy (proxy resolves hostnames, sends domain)

The difference between socks5:// and socks5h:// matters when the proxy is on a different network (e.g. a corporate SOCKS proxy that can resolve internal hostnames the client cannot).

Auto-Detection from URL

ProxyConfig::detect_from_url() detects the proxy scheme from a URL string and returns the appropriate ProxyConfig:

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

// Recognised schemes
let http  = ProxyConfig::detect_from_url("http://proxy:8080");    // ProxyScheme::Http
let https = ProxyConfig::detect_from_url("https://proxy:443");    // ProxyScheme::Https
let s5    = ProxyConfig::detect_from_url("socks5://proxy:1080");  // ProxyScheme::Socks5
let s5h   = ProxyConfig::detect_from_url("socks5h://proxy:1080"); // ProxyScheme::Socks5h
let s4    = ProxyConfig::detect_from_url("socks4a://proxy:1080"); // SOCKS4a proxy

// Bare hostname:port — defaults to http://
let bare  = ProxyConfig::detect_from_url("proxy:3128");           // ProxyScheme::Http
}

This is used internally by ProxySettings::from_env() for environment variable parsing and by the CLI’s -x / --proxy flag.

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()?;

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

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

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

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

URI-Embedded Credentials

Proxy URLs can include credentials in the standard user:pass@host format. Both the username and password are percent-decoded automatically.

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

// Credentials embedded in the URL
let proxy = ProxyConfig::http("http://alice:s3cret@proxy.example.com:8080").unwrap();

// Percent-encoded characters are decoded (e.g. %40 → @, %3A → :)
let proxy = ProxyConfig::https(
    "https://user%40domain:p%3Assword@proxy.example.com:443"
).unwrap();

// basic_auth() still works and overrides any URI-embedded credentials
let proxy = ProxyConfig::http("http://ignored:ignored@proxy:8080")
    .unwrap()
    .basic_auth("real-user", "real-pass");
}

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. The following URL schemes are recognised: http://, https://, socks4://, socks4a://, socks5://, and socks5h://.

System proxy support is environment-based on native runtimes and blocking clients. Wasm/browser and wasi-p2 transports are host-managed; proxy discovery, DNS, and bypass behavior come from the browser or WASI host rather than aioduct’s native proxy stack.

Runtime Scope

RuntimeProxy configurationBypass matchingNotes
TokioNative stackNoProxyHTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h
smolNative stackNoProxySame behavior as Tokio for send clients
compioNative stackNoProxySame behavior through local clients
blockingWrapped native clientWrapped native clientInherits the configured async client behavior
wasmBrowser-managedBrowser-managedThe browser decides proxy routing and bypass rules
wasi-p2Host-managedHost-managedThe WASI host decides proxy routing and bypass rules

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
10.0.0.0/8IPv4 CIDR match
2001:db8::/32IPv6 CIDR match
example.com:8080Hostname only when the request port is 8080
[2001:db8::1]:443IPv6 literal only when the request port is 443

Host matching is case-insensitive. A bare hostname rule matches that hostname and its subdomains; a leading-dot rule matches subdomains only.

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

Proxy authentication is supported via basic_auth(), URI-embedded credentials, or a credential resolver.

Explicit 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()?;
}

Custom CONNECT Headers

For HTTP and HTTPS proxies (which tunnel via CONNECT), header() attaches extra headers to the CONNECT request — useful for proxy auth tokens or routing headers beyond Basic auth. SOCKS proxies have no header phase; using CONNECT headers with a SOCKS proxy fails explicitly when the proxy is used. Different CONNECT headers segregate pooled connections for HTTP and HTTPS proxies.

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

let client = TokioClient::builder()
    .proxy(
        ProxyConfig::http("http://proxy.example.com:8080")
            .unwrap()
            .header(
                HeaderName::from_static("x-proxy-token"),
                HeaderValue::from_static("secret-token"),
            ),
    )
    .build()?;
}

Credential Resolver

The CredentialResolver trait allows looking up proxy credentials from external sources. It is called when a proxy has no explicit basic auth or Proxy-Authorization CONNECT header. Resolver keys use the proxy’s canonical host:port, including the scheme’s default port when the URI omits it.

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

// Built-in: read from environment variables
use aioduct::EnvCredentialResolver;

// Reads AIODUCT_PROXY_USER and AIODUCT_PROXY_PASS globally.
// The `key` parameter is the canonical proxy host:port and can be used by
// per-proxy resolvers such as platform keychains.
let client = TokioClient::builder()
    .proxy_settings(
        ProxySettings::all(
            ProxyConfig::http("http://proxy:8080").unwrap()
        )
        .proxy_credential_resolver(EnvCredentialResolver),
    )
    .build()?;
}

Composite resolvers try multiple sources in order:

#![allow(unused)]
fn main() {
use aioduct::{CompositeResolver, EnvCredentialResolver, CredentialResolver};

struct KeychainResolver;
impl CredentialResolver for KeychainResolver {
    fn resolve(&self, key: &str) -> Option<(String, String)> {
        // Look up credentials in the platform keychain by host:port
        None
    }
}

let resolver = CompositeResolver::new()
    .push(KeychainResolver)
    .push(EnvCredentialResolver); // fallback

let client = TokioClient::builder()
    .proxy_settings(
        ProxySettings::all(
            ProxyConfig::http("http://proxy:8080").unwrap()
        )
        .proxy_credential_resolver(resolver),
    )
    .build()?;
}

basic_auth() overrides URI-embedded credentials. Basic/URI authentication and an explicit Proxy-Authorization CONNECT header are mutually exclusive; the credential resolver is used only when neither explicit source is present.

Proxy Chaining

Proxy chaining routes requests through multiple proxies in sequence. Each proxy is reached through the previous one. Up to 2 hops are currently supported.

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

// Chain: client → SOCKS5 exit proxy → corporate HTTP proxy → target
let chain = ProxyChain::new(vec![
    ProxyConfig::socks5("socks5://exit-proxy:1080").unwrap(),
    ProxyConfig::http("http://corporate-proxy:3128")
        .unwrap()
        .basic_auth("employee", "pass"),
]);

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

Any combination of HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h can be used for both hops:

First hopValid second hops
HTTPHTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h
HTTPSHTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h
SOCKS4 or SOCKS4aHTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h
SOCKS5 or SOCKS5hHTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h

This includes HTTPS-to-SOCKS chains: the SOCKS handshake runs over the TLS stream established to the first HTTPS proxy.

When both a proxy chain and a single proxy are configured, the chain takes priority.

How It Works

HTTP Targets

For plain HTTP requests through an HTTP proxy, the client uses a CONNECT tunnel to the target through the proxy, then sends the request through the tunnel. This is a transparent tunnel — the proxy relays raw TCP bytes between the client and the target.

HTTPS Targets (CONNECT Tunnel)

For HTTPS requests through an HTTP proxy, 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 successful 2xx 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.

Proxy plans are validated before DNS or TCP I/O. This includes rejecting non-textual HTTP CONNECT header values, NUL-containing SOCKS4/SOCKS4a user IDs, SOCKS4/SOCKS4a IPv6 targets, and SOCKS5 credentials longer than the protocol’s 255-byte fields. DNS, TCP, proxy TLS, CONNECT, and origin TLS observer phases are emitted when each phase completes, rather than being buffered until the whole proxy attempt succeeds or fails.

HTTPS Proxy

When the proxy URL itself uses https://, the client wraps the connection to the proxy in TLS before sending the CONNECT command. This encrypts the CONNECT handshake (including target hostname and proxy credentials) from any intermediary between the client and the proxy.

An ECH-enabled rustls configuration intended for the origin cannot be reused for this outer proxy TLS connection. aioduct rejects an HTTPS proxy route with such a configuration before proxy transport I/O rather than offering the origin’s ECH configuration to the proxy. Use a TLS configuration without ECH for HTTPS proxy routes, or a proxy scheme that does not add an outer TLS layer.

SOCKS Proxies

SOCKS proxies operate at the TCP level. After the SOCKS handshake (which establishes a tunnel to the target), the TCP stream is used directly — for HTTP targets the client sends a normal request, for HTTPS targets TLS is negotiated over the tunnel.

HTTP/3 Policy

When a proxy is configured (via .proxy(), .proxy_settings(), or .proxy_chain()), the client never attempts a direct HTTP/3 connection to the origin. HTTP/3 proxy tunneling (CONNECT-UDP, RFC 9298) is not yet supported. Proxied requests use the configured HTTP/1.1 or HTTP/2 tunnel path instead, even when .http3(true) or .alt_svc_h3(true) is active on the builder. Non-proxied requests are unaffected and may use HTTP/3 normally.

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(())
}

CLI Proxy Support

The aioduct http and aioduct download subcommands support all proxy schemes via the -x / --proxy and --all-proxy flags:

# HTTP proxy
aioduct http -x http://proxy:8080 https://example.com

# SOCKS5 proxy
aioduct http -x socks5://127.0.0.1:1080 https://example.com

# SOCKS5h proxy (remote DNS)
aioduct http -x socks5h://proxy:1080 https://internal.corp

# HTTPS proxy
aioduct http -x https://proxy:443 https://example.com

# Multi-hop proxy chaining (repeated -x)
aioduct http -x socks5://gateway:1080 -x http://corp:3128 https://example.com

# Proxy with auth
aioduct http -x http://proxy:8080 --proxy-user admin:secret https://example.com

# System proxy from environment variables
aioduct http --system-proxy https://example.com

# Proxy with bypass rules
aioduct http -x http://proxy:8080 --noproxy localhost,127.0.0.1,.internal \
  https://example.com

Proxy Compatibility Matrix

Every proxy feature is listed below across all runtimes and deployment targets. Platform-managed transports (wasm, wasi-p2) delegate proxy routing and bypass to the browser or WASI host; aioduct does not control proxy behavior on those targets.

FeatureTokio / smol / compioblockingwasmwasi-p2
HTTP proxy (CONNECT tunnel)YesYes, via wrapped async clientBrowser-managedHost-managed
HTTPS proxy (TLS to proxy)YesYesBrowser-managedHost-managed
Origin ECH configuration through HTTPS proxyRejected before proxy transport I/ORejected before proxy transport I/OBrowser-managedHost-managed
SOCKS4 / SOCKS4aYesYesNot availableNot available
SOCKS5 / SOCKS5hYesYesNot availableNot available
Proxy auth (Basic, URI-embedded)YesYesBrowser-managedHost-managed
Credential resolverYesYesNot availableNot available
Custom CONNECT headersYesYesNot availableNot available
System proxy (env vars)YesYesNot availableNot available
NO_PROXY bypass rulesYesYesNot availableNot available
Custom proxy selectionYesYesNot availableNot available
Proxy chaining (up to 2 hops)YesYesNot availableNot available
HTTP/3 with proxy (CONNECT tunnel fallback)YesYesN/AN/A
Redirect through proxy (auth survives hops)YesYesBrowser-managedHost-managed
TCP keepalive on proxy tunnelYesYesN/AN/A

Future Work

ItemNotes
Windows machine-scope system proxySystem proxy currently means environment variables. Windows registry / WinHTTP proxy discovery is planned.
NTLM proxy authenticationOnly Basic auth, URI-embedded credentials, and credential resolvers are available.
CONNECT-UDP (RFC 9298)HTTP/3 with a proxy falls back to the configured HTTP/1.1 or HTTP/2 CONNECT tunnel.
Per-request proxy overrideProxy configuration is per-client (builder).

Limitations

  • SOCKS5 supports no-auth and username/password authentication (RFC 1928/1929)
  • SOCKS4 and SOCKS4a support optional user ID authentication
  • Proxy chaining supports up to 2 hops
  • CONNECT headers on SOCKS proxies are rejected with a clear error before any I/O.
  • HTTP CONNECT headers must contain textual values that can be encoded on the HTTP/1.1 CONNECT request.
  • SOCKS4 and SOCKS4a cannot carry IPv6 destinations, and their user IDs cannot contain NUL bytes.
  • SOCKS5 usernames and passwords are limited to 255 bytes each.
  • EnvCredentialResolver applies the same credentials to all proxies and ignores the resolver key. Custom resolvers can use the canonical key for per-proxy resolution.
  • An ECH-enabled origin rustls configuration cannot be used for the outer TLS connection to an HTTPS proxy; the route is rejected before proxy transport I/O.
  • The HTTP proxy URI must use http:// or https:// scheme; SOCKS proxies must use socks4://, socks4a://, socks5://, or socks5h://

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)
            .max_concurrent_reset_streams(128),
    )
    .build()?;
}

Blocking clients for Tokio, smol, and compio use the same engine configuration as async clients. Build the runtime client with the HTTP/2 options you need, then wrap it:

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

let async_client = TokioClient::builder()
    .http2_keep_alive_interval(Duration::from_secs(30))
    .http2_keep_alive_timeout(Duration::from_secs(10))
    .http2_keep_alive_while_idle(true)
    .build()?;
let client = BlockingTokioClient::new(async_client);
}

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 Hyper keeps in reset state

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);
}

Reset Stream State

Hyper keeps recently locally-reset HTTP/2 streams in memory for a short period so late frames on those streams can be ignored as required by the protocol. Use max_concurrent_reset_streams to bound that per-connection state:

#![allow(unused)]
fn main() {
use aioduct::Http2Config;
let config = Http2Config::new()
    .max_concurrent_reset_streams(128);
}

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
  • Reset-heavy workloads: Bound locally-reset stream state

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()
    .build()?;

let mut req = client
    .get("http://example.com/ws/chat")?
    .h2c_prior_knowledge()
    .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 RequestBuilderSend or RequestBuilderLocal 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 UpgradedSend stream.
  5. The connection is not returned to the pool — it’s exclusively yours.

The UpgradedSend Type

UpgradedSend 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 UpgradedSend::from()

The local runtime path returns UpgradedLocal.

Using with WebSocket Libraries

Pass the UpgradedSend 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).

The runnable forward-multipart example receives a real Request<hyper::body::Incoming> in a Hyper handler and streams its multipart file body through forward. Equivalent examples are available for smol and compio.

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
.downstream_target_uri(uri)Full downstream URI for related-request response signatures
.response_content_digest(max_bytes)Buffer the downstream response up to a cap and insert SHA-256 Content-Digest
.response_message_signature(config, signer)Sign the downstream response with a sync RFC 9421 signer
.response_message_signature_async(config, signer)Sign the downstream response with a send-runtime async signer (send builders)
.response_message_signature_async_local(config, signer)Sign the downstream response with a local-runtime async signer (local builders)
.h2c()Force HTTP/2 prior knowledge (h2c) on this forward
.adaptive_h2c()Probe h2c, fall back to h1; result cached per effective route and forced address
.upgrade()Force upgrade header preservation (usually auto-detected)

Hop-by-Hop Header Stripping

ForwardBuilderSend and ForwardBuilderLocal automatically strip hop-by-hop fields from both the incoming request and the upstream response:

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

Use .forward_header(name) to preserve ordinary headers through the upstream rewrite. It does not override protocol safety rules for hop-by-hop fields.

Protocol-specific fields are handled after the final request hook and selected upstream protocol are known. When HTTP/1.1 trailer negotiation applies, aioduct regenerates canonical Connection: TE and TE: trailers fields; HTTP/1.0 removes TE. HTTP/2 and HTTP/3 may retain only canonical TE: trailers. Upgrade is restored only for a validated HTTP/1.1 upgrade, and HTTP2-Settings only for a valid h2c upgrade. Fields named by Connection are removed unless that upgrade policy explicitly restores them.

Trailer declarations are preserved for end-to-stream metadata, but names that are forbidden in trailer fields are removed from the declaration. Actual trailer frames are sanitized on both request and response bodies. Framing, routing, authentication, request-control, response-control, and payload interpretation fields such as Content-Length, Host, Authorization, Set-Cookie, and Content-Type are never forwarded as trailers. Extension metadata such as X-Upload-Checksum remains eligible.

Actual HTTP/3 request and response trailer frames currently fail closed with Error::Unsupported, even when their fields would otherwise be eligible.

WebSocket / HTTP Upgrade Forwarding

Upgrade requests are auto-detected and handled correctly:

HTTP/1.1 Upgrade

When Connection: Upgrade is present, both forward builders:

  • Preserves Connection and Upgrade headers through hop-by-hop stripping
  • Forces HTTP/1.1 on the upstream connection
  • Sanitizes the 101 response, then restores only the validated Connection: upgrade and Upgrade fields required for the tunnel
#![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("host", "proxy.example")
    .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, both forward builders:

  • Forces HTTP/2 on the upstream connection
  • Uses the full URI (not path-only) so hyper generates correct pseudo-headers
  • Validates and sanitizes response headers and trailers before tunnel handoff
#![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()
    .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())
    .h2c()
    .send()
    .await?;

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

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

HTTP/2 tunnel handoff currently requires status 200 OK for both ordinary and extended CONNECT. Hyper 1.10 does not expose the bidirectional stream for other successful 2xx statuses, so aioduct returns an explicit unsupported error instead of exposing a one-way or false tunnel. HTTP/1.1 CONNECT continues to accept the full successful 2xx range.

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").header("host", "proxy.example").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(())
}
}

Response Message Signatures

Forward builders can sign the response that a gateway returns downstream with RFC 9421 HTTP Message Signatures. Response signing runs after upstream response hop-by-hop headers are stripped and after on_response runs, then strips hop-by-hop headers again before building the signature base. This means response mutations from the hook are covered, but Connection-listed fields are not.

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig, 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()
    .method("GET")
    .uri("/api/users")
    .header("host", "gateway.example.com")
    .body(Full::new(Bytes::new()))
    .unwrap();

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::status())
    .component(MessageSignatureComponent::header(
        http::header::HeaderName::from_static("x-gateway"),
    ));

let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .on_response(|resp| {
        resp.headers_mut().insert("x-gateway", "aioduct".parse().unwrap());
    })
    .response_message_signature(config, |base: &[u8]| Ok(sign_with_your_key(base)))
    .send()
    .await?;
let _ = resp;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

When the response signature covers related request components with ;req, the request data comes from the inbound request snapshot, not the rewritten upstream request. If the inbound request uses origin-form and the signature covers related-request @scheme, @authority, or @target-uri, provide the full downstream URI explicitly:

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig, 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()
    .method("GET")
    .uri("/api/users?limit=10")
    .header("host", "gateway.example.com")
    .body(Full::new(Bytes::new()))
    .unwrap();

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::status())
    .component(MessageSignatureComponent::target_uri().related_request())
    .component(MessageSignatureComponent::authority().related_request());

let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .downstream_target_uri("https://gateway.example.com/api/users?limit=10")
    .response_message_signature(config, |base: &[u8]| Ok(sign_with_your_key(base)))
    .send()
    .await?;
let _ = resp;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Use .response_content_digest(max_bytes) when the gateway should add a downstream response body digest before response signing:

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig, 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()
    .method("GET")
    .uri("/api/report")
    .body(Full::new(Bytes::new()))
    .unwrap();

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::status())
    .component(MessageSignatureComponent::header(
        http::header::HeaderName::from_static(aioduct::CONTENT_DIGEST),
    ));

let resp = client
    .forward(incoming_req)
    .upstream("http://backend:8080".parse::<http::Uri>().unwrap())
    .response_content_digest(64 * 1024)
    .response_message_signature(config, |base: &[u8]| Ok(sign_with_your_key(base)))
    .send()
    .await?;
let _ = resp;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Response digest generation runs after upstream response hop-by-hop cleanup and on_response, then before response signing. It preserves an existing Content-Digest without buffering. If the response body exceeds max_bytes, the forward returns an error instead of producing an undigested response. Bodyless responses such as HEAD, 204, 205, and 304 are not assigned synthesized digest fields.

Automatic response finalization is fail-closed: signer failures, malformed existing Signature-Input / Signature dictionaries, unsupported trailer components, response digest bodies over the configured cap, CONNECT, known upgrade requests, and HTTP/1.1 101 Switching Protocols responses return an error instead of an unsigned or undigested response.

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 applying that protocol policy to every request made by the 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 for an unknown effective route and endpoint, it probes with an h2 prior-knowledge handshake. If the upstream rejects it, the request falls back to HTTP/1.1 transparently. The cache key includes the origin scheme, host, effective port, complete proxy route, and any forced transport address. The same authority reached directly, through a proxy, or through different forced addresses is therefore probed independently:

#![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 on this route use the 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 Forward Builders Do NOT Do

  • No request body buffering — the incoming request body streams through as-is
  • No middleware — redirects, cookies, cache, and decompression are all bypassed
  • No streaming response digesting — response Content-Digest generation uses bounded full-body buffering, not trailers
  • No automatic trailer finalization — response signing does not synthesize digest or signature trailers while streaming downstream bodies
  • No WebSocket framing — aioduct is transport-level; use a WS library for frame parsing
  • No bidirectional splice — the caller is responsible for splicing upgrade 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"));
}

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 binary is a unified HTTP toolkit providing two subcommands: a curl-style HTTP client and an aria2-style parallel downloader. Both share the same connection pool, TLS stack, and HTTP/2 implementation from the aioduct library.

Installation

Shell installer (no Rust toolchain needed):

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/adamcavendish/aioduct/releases/download/0.2.5/aioduct-cli-installer.sh | sh

Nightly build (latest main):

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/adamcavendish/aioduct/releases/download/nightly/aioduct-cli-installer.sh | sh

Build from source:

cargo install --git https://github.com/adamcavendish/aioduct aioduct-cli

Subcommands

CommandDescription
aioduct httpHTTP request tool with verbose TUI, redirects, retries, proxy
aioduct downloadParallel segmented downloader with resume, WebDAV recursion
aioduct versionPrint version and exit

Size Suffixes

Both subcommands accept human-readable size values for bandwidth and segment options:

SuffixMultiplier
(none)bytes
K / kx 1024
M / mx 1024²
G / gx 1024³

Decimal fractions are supported: 1.5M = 1,572,864 bytes.

Comparison

vs curl: aioduct http covers the most commonly used curl flags with the same short options (-X, -d, -H, -o, -L, -v, etc.). The verbose mode (-v) provides a real-time ratatui TUI showing DNS/TCP/TLS/TTFB phases as a timeline, rather than plain text headers.

vs aria2c: aioduct download implements segmented parallel downloads with automatic resume, similar to aria2c. It adds WebDAV recursive directory downloads, checksum verification, and a TUI progress display. The flag names follow aria2 conventions (--split, --max-concurrent-downloads, --min-split-size).

Shared advantages: Both subcommands benefit from the aioduct library’s HTTP/2 multiplexing, connection pooling, bandwidth limiting, and proxy support (HTTP, HTTPS, SOCKS4/SOCKS4a, SOCKS5, SOCKS5h) without external dependencies like libcurl or OpenSSL.

aioduct http

A curl-style HTTP client with familiar flags and a real-time verbose TUI for inspecting request lifecycles.

Basic Usage

# GET request
aioduct http https://httpbin.org/get

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

# Form POST
aioduct http -F 'user=adam' -F 'action=deploy' https://httpbin.org/post

# Upload binary file
aioduct http -X PUT --data-binary @./artifact.tar.gz \
  -H 'Content-Type: application/octet-stream' \
  https://storage.example.com/uploads/artifact.tar.gz

# HEAD request — show response headers only
aioduct http -I https://example.com

Verbose Mode

The -v flag activates verbose output showing the full request lifecycle.

When stdout is a terminal, verbose mode launches a ratatui TUI with a 6-tab interface navigated via Tab / Shift+Tab:

Tabs

TabContent
OverviewPhase timeline waterfall chart with per-phase durations
TraceDNS resolution, TCP connect, TLS handshake, request/response headers, timing
HeadersFull request and response headers with filter
BodyResponse body preview (binary detection with hex dump fallback, SSE streaming)
EventsChronological event log of every phase transition, redirect, retry, and error
SummaryTransfer metrics, redirect/retry history, trailers, timings

Controls

KeyAction
Tab / Shift+TabNext / previous tab
Scroll
cCopy visible content to clipboard
/Filter / search within current tab
hToggle help overlay
qQuit

Features

  • Binary body detection: Binary content types (images, gzip, protobuf, etc.) are auto-detected; headers are redacted and body shown as hex dump instead of garbled text.
  • Trailer display: HTTP trailers received after the body appear in the Headers and Summary tabs.
  • SSE tracking: Server-Sent Events are parsed; event count, first/last timestamps, and gaps are tracked.
  • Error state rendering: Failed phases show the error reason inline in the Timeline and Trace tabs.
  • Redirect & retry panels: The Summary tab shows redirect chains and retry attempts with backoff durations and reasons.
  • Event log sanitization: ANSI escapes are stripped, newlines collapsed, control characters replaced for safe TUI rendering.

When stdout is not a terminal (piped), verbose output falls back to colored stderr text:

# TUI mode (terminal)
aioduct http -v https://example.com

# Force plain text verbose (always stderr, even on a terminal)
aioduct http --verbose-plain https://example.com

# Pipe-friendly: body to stdout, verbose to stderr
aioduct http -v https://api.example.com/data | jq .

Authentication

# HTTP Basic auth
aioduct http -u admin:secret https://httpbin.org/basic-auth/admin/secret

# Bearer token
aioduct http --oauth2-bearer eyJhbGciOi... https://api.example.com/protected

Output Control

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

# Save using filename from URL
aioduct http -O https://releases.example.com/v2.1/archive.tar.gz

# Dump response headers to file
aioduct http -D headers.txt https://example.com

# Include response headers in stdout
aioduct http -i https://example.com

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

Redirects & Retries

# Follow redirects (up to 10 hops by default)
aioduct http -L https://httpbin.org/redirect/3

# Custom redirect limit
aioduct http -L --max-redirs 5 https://httpbin.org/redirect/3

# Retry on failure with exponential backoff
aioduct http --retry 5 --retry-max-time 120 https://flaky-service.example.com/health

Transport

# Force HTTP/2 prior knowledge
aioduct http --http2 https://example.com

# Request compressed response
aioduct http --compressed https://cdn.example.com/large-payload.json

# Limit download speed
aioduct http --limit-rate 1M https://cdn.example.com/file.bin

# HTTP proxy
aioduct http -x http://proxy:8080 https://example.com

# HTTPS proxy (TLS-wrapped connection to proxy)
aioduct http -x https://proxy:443 https://example.com

# SOCKS5 proxy (local DNS)
aioduct http -x socks5://127.0.0.1:1080 https://example.com

# SOCKS5h proxy (remote DNS — proxy resolves hostnames)
aioduct http -x socks5h://proxy:1080 https://internal.corp

# SOCKS4/SOCKS4a proxy
aioduct http -x socks4a://localhost:1080 https://example.com

# Multi-hop proxy chaining (repeated -x)
aioduct http -x socks5://internal-gateway:1080 -x http://corp-proxy:3128 \
  https://example.com

# Proxy with explicit authentication
aioduct http -x http://proxy:8080 --proxy-user admin:secret \
  https://example.com

# Proxy bypass for specific hosts
aioduct http -x http://proxy:8080 --noproxy localhost,127.0.0.1,.internal \
  https://example.com

# Use system proxy settings (HTTP_PROXY / HTTPS_PROXY / NO_PROXY)
aioduct http --system-proxy https://example.com

# Skip TLS verification
aioduct http -k https://self-signed.example.com

# Timeouts
aioduct http --connect-timeout 5 --max-time 30 https://slow-server.example.com

Flags Reference

FlagLongDescription
-X--requestHTTP method
-d--dataRequest body (implies POST); prefix @ to read from file
--data-binaryBinary body; prefix @ to read from file
-F--formURL-encoded form 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--verboseVerbose mode (TUI on terminal, plain text otherwise)
--verbose-plainForce plain-text verbose to stderr
-s--silentSilent mode
-S--show-errorShow errors in silent mode
-o--outputWrite body to file
-O--remote-nameSave using filename from URL
-D--dump-headerDump response headers to file
-w--write-outFormat string (%{http_code}, %{response_code})
-m--max-timeTotal request timeout (seconds)
--connect-timeoutConnection timeout (seconds)
--retryRetry count
--retry-max-timeMax backoff between retries (default: 60s)
-x--proxyProxy URL (repeatable for multi-hop chaining)
--proxy-userProxy auth (user:password)
--noproxyBypass proxy for listed hosts (comma-separated)
--system-proxyUse proxy from env vars (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
-k--insecureSkip TLS verification
--http2Force HTTP/2 prior knowledge
--limit-rateMax download speed (supports K/M/G)
--rawDisable decompression
--compressedRequest compressed response

Exit Codes

CodeMeaning
0Success
1Generic error
3Invalid URL
7Connection / I/O error
22HTTP 4xx/5xx response
23Output write error
28Timeout
60TLS error

Examples

# Query an LLM API with bearer auth and parse the JSON response
aioduct http -X POST \
  --oauth2-bearer sk-abc123 \
  -H 'Content-Type: application/json' \
  -d '{"model":"claude-3","prompt":"Hello"}' \
  https://api.anthropic.com/v1/messages | jq .content

# CI health check — exit non-zero on failure
aioduct http -s -o /dev/null -w '%{http_code}' \
  --max-time 5 --retry 3 \
  https://production.example.com/healthz

# Upload a release artifact through a corporate proxy
aioduct http -X PUT --data-binary @./build/release.tar.gz \
  -H 'Content-Type: application/gzip' \
  -x http://corporate-proxy:3128 \
  -u deploy:token \
  https://artifacts.example.com/releases/v2.1/release.tar.gz

# Inspect TLS and timing details with verbose plain output
aioduct http --verbose-plain --http2 https://example.com 2>&1 | grep -E 'TLS|RESP'

# Download with speed limit and save to specific file
aioduct http --limit-rate 500K -o large-file.bin \
  https://cdn.example.com/datasets/training-data.bin

# Silent mode with write-out for monitoring scripts
aioduct http -s -o /dev/null \
  -w 'status=%{http_code}\n' \
  --max-time 10 \
  https://api.example.com/status

aioduct download

An aria2-style parallel downloader that splits files into segments and fetches them concurrently using HTTP Range requests.

Basic Usage

# Download with auto-detected filename, 8 parallel segments
aioduct download https://releases.example.com/archive-2.1.tar.gz

# Custom output directory and filename
aioduct download -d ./downloads -o my-archive.tar.gz \
  https://releases.example.com/archive-2.1.tar.gz

# Multiple URIs
aioduct download \
  https://mirror1.example.com/file1.iso \
  https://mirror2.example.com/file2.iso

# Download from a URI list file
aioduct download -i urls.txt -d ./batch-output

Parallelism

# 16 parallel segments per file
aioduct download -s 16 https://cdn.example.com/large-file.bin

# Limit connections per server
aioduct download -x 4 https://cdn.example.com/large-file.bin

# 3 concurrent downloads from a list
aioduct download -j 3 -i urls.txt

# Set minimum segment size (skip splitting small files)
aioduct download -k 10M https://cdn.example.com/large-file.bin

The downloader probes the server for Accept-Ranges support. If the server does not support range requests, it falls back to a single-connection download.

Resume & Integrity

Re-running the same command automatically resumes interrupted downloads. Completed segments are skipped.

# Resume an interrupted download (just re-run)
aioduct download https://slow-server.example.com/10gb-backup.tar.zst

# Disable resume (re-download from scratch)
aioduct download --no-resume https://example.com/file.bin

# Verify checksum after download
aioduct download --checksum sha-256=e3b0c44298fc1c149afbf4c8996fb924... \
  https://releases.example.com/critical-binary

WebDAV Recursive

Download entire directory trees from WebDAV servers. The URL must end with / to trigger directory listing.

# Recursive download of a WebDAV directory
aioduct download -r https://webdav.example.com/shared/project-assets/

# Limit recursion depth
aioduct download -r --max-depth 2 https://webdav.example.com/docs/

Speed Limiting

# Global speed cap across all concurrent downloads
aioduct download --max-overall-download-limit 10M -j 5 -i urls.txt

# Per-download speed cap
aioduct download --max-download-limit 2M https://cdn.example.com/file.bin

Dry Run

Probe URIs without downloading. Reports file size, range support, and output path.

aioduct download --dry-run https://cdn.example.com/huge-dataset.parquet

Progress Display

By default, the downloader shows a ratatui TUI with per-file progress bars, speed, and ETA.

# Plain newline-based progress (no TUI)
aioduct download --plain https://example.com/file.bin

# Suppress all output
aioduct download -q https://example.com/file.bin

# Debug logging to file
aioduct download --log download.log --log-level debug https://example.com/file.bin

Flags Reference

FlagShortDescription
URI...Download URIs (positional, repeatable)
--input-file FILE-iRead URIs from file (one per line)
--dir PATH-dOutput directory (default: .)
--out FILENAME-oOutput filename (single URI only)
--split N-sParallel connections per download (default: 8)
--max-connection-per-server N-xMax connections per server (default: 8)
--max-concurrent-downloads N-jMax concurrent downloads (default: 5)
--min-split-size SIZE-kMinimum segment size (default: 1M)
--piece-size SIZEOverride piece size (auto if unset)
--no-resumeDisable automatic resume
--file-allocation METHODnone, prealloc (default), or falloc
--auto-file-renamingRename output if file exists
--allow-overwriteOverwrite existing files
--checksum TYPE=DIGESTVerify integrity (e.g. sha-256=abc...)
--timeout SECS-tPer-request timeout (default: 60s)
--connect-timeout SECSConnection timeout (default: 30s)
--max-tries N-mMax retry attempts (default: 5)
--retry-wait SECSSeconds between retries (default: 1)
--max-overall-download-limit SIZEGlobal speed cap
--max-download-limit SIZEPer-download speed cap
--header NAME:VALUE-HExtra HTTP header (repeatable)
--referer URLReferer header
--user-agent STRING-UUser-Agent string
--http-user USERHTTP Basic auth username
--http-passwd PASSHTTP Basic auth password
--all-proxy URLProxy for all protocols (http, https, socks4, socks4a, socks5, socks5h)
--check-certificate-falseDisable TLS verification
--recursive-rRecursive WebDAV download
--max-depth NMax recursion depth (default: unlimited)
--dry-runProbe without downloading
--quiet-qSuppress all output
--plainPlain text progress (no TUI)
--human-readableHuman-readable sizes (default: true)
--log FILE-lWrite debug log to file
--log-level LEVELtrace, debug, info, warn, error

Examples

# Batch download with global speed limit and plain progress
aioduct download -j 3 --max-overall-download-limit 20M --plain -i urls.txt

# Mirror a WebDAV directory with auth
aioduct download -r \
  --http-user deploy --http-passwd s3cret \
  https://webdav.example.com/releases/

# CI artifact fetch — fast, limited bandwidth, verified
aioduct download -s 16 \
  --max-download-limit 50M \
  --checksum sha-256=a1b2c3d4... \
  -d ./artifacts \
  https://ci.example.com/builds/latest/output.tar.gz

# Probe multiple files before downloading
aioduct download --dry-run \
  https://cdn.example.com/dataset-part1.parquet \
  https://cdn.example.com/dataset-part2.parquet \
  https://cdn.example.com/dataset-part3.parquet

# Download through SOCKS5 proxy with custom user-agent
aioduct download --all-proxy socks5://127.0.0.1:1080 \
  -U 'aioduct-bot/1.0' \
  https://private.example.com/internal-build.deb

Benchmarks

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

CrateVersionDescription
aioduct0.2.5This 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 benchmarks
cargo bench -p aioduct-bench

# All benchmarks through just
just bench

# Run a benchmark group by Criterion name filter
cargo bench -p aioduct-bench --bench bench_main -- e2e_h1
just bench-group e2e_h1/get_small

# Save and compare local Criterion baselines
just bench-save main
just bench-compare main

# Emit bencher-compatible output for continuous benchmarking
cargo bench -p aioduct-bench --bench bench_main \
  -- --output-format bencher --noplot --color never

HTML reports are generated in target/criterion/.

Continuous Benchmarking

The GitHub benchmark workflow runs the full Criterion suite on pushes to main, once per day, and on manual dispatch. It emits bencher-compatible output for benchmark-action/github-action-benchmark, stores benchmark history in the workflow cache, writes a job summary, and reports an alert when a benchmark is more than 200% of the previous result for the same branch. Alerts are informational rather than merge-blocking because absolute loopback timings on GitHub-hosted runners can vary substantially with the assigned host. A regression should be confirmed on a stable machine and against the comparison clients from the same run. After saving the cache, the benchmark workflow triggers the Pages workflow, which restores the latest benchmark cache and publishes the chart dashboard at https://adamcavendish.github.io/aioduct/dev/bench/.

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
e2e_h1benches/bench_main/e2e_h1.rsHTTP/1.1 GET bytes/text, POST 4K, download 64K/1M
e2e_h2benches/bench_main/e2e_h2.rsHTTP/2 GET, POST 4K, download 64K/1M
e2e_concurrentbenches/bench_main/e2e_concurrent.rsHTTP/1.1 and HTTP/2 concurrent requests
e2e_featuresbenches/bench_main/e2e_features.rsSSE, multipart, upload 1M, chunk download, body stream, JSON
e2e_poolingbenches/bench_main/e2e_pooling.rsHTTP/1.1 and HTTP/2 with-pool vs no-pool
runtimebenches/bench_main/e2e_runtime.rsTokio, smol, and compio runtime comparisons
micro_poolbenches/bench_main/micro_pool.rsPool checkout/check-in and coalescing scans
micro_cookiebenches/bench_main/micro_cookie.rsCookie request application and response storage
micro_bodybenches/bench_main/micro_body.rsBody frame polling through middleware layers

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))
    .connect_timeout(Duration::from_secs(10))
    .max_redirects(5)
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_lifetime(Duration::from_secs(600))
    .pool_max_idle_per_host(10)
    .pool_max_active_per_host(64)
    .pool_max_active_streams_per_connection(100)
    .build()?;
}

Tokio, smol, and compio share the same native client builder surface. Blocking clients wrap an already-configured async or local client, so pool, timeout, TLS, proxy, retry, and HTTP/2 keep-alive settings are preserved by the wrapper. Wasm and wasi-p2 clients use platform-managed transports, so connection pooling, DNS, proxy, and TLS details are controlled by the browser or WASI host.

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
base_url(&str)NoneBase URL that relative request URLs resolve against (RFC 3986)
timeout(Duration)NoneOverall request deadline
connect_timeout(Duration)NoneOne connection-acquisition deadline across pool coordination, DNS, transport, proxy, TLS, and protocol setup
read_timeout(Duration)NoneTimeout between response body chunks
write_timeout(Duration)NoneTimeout between request body chunks (upload)
tcp_keepalive(Duration)NoneEnable TCP keepalive with given interval
local_address(IpAddr)NoneBind outgoing connections to a local IP
address_family(AddressFamily)AnyRestrict/prefer IP family (Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6) for resolved connections
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_lifetime(Duration)NoneMaximum connection age before reuse stops
pool_max_idle_per_host(usize)10Max idle connections per origin
pool_max_active_per_host(usize)UnlimitedMax checked-out handles and fresh connection attempts per pool key; 0 disables the cap
pool_max_active_streams_per_connection(usize)UnlimitedMax active HTTP/2 or HTTP/3 streams per pooled connection
default_headers(HeaderMap)User-AgentHeaders applied to every request
no_default_headers()Remove all default headers
tls(RustlsConnector)NoneCustom rustls configuration, including caller-built ECH configs
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
automatic_content_digest(bool)falseInsert SHA-256 Content-Digest for buffered native request bodies before automatic signing
message_signature(config, signer)NoneSync automatic RFC 9421 request signing for finalized native requests
message_signature_async(config, signer)NoneSend-runtime async automatic RFC 9421 request signing
message_signature_async_local(config, signer)NoneLocal-runtime async automatic RFC 9421 request signing
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

base_url(&str) returns Result because it validates the URL eagerly; the other setters return Self. When a base URL is set, a relative request URL (e.g. client.get("users")) resolves against it per RFC 3986, while an absolute request URL overrides it.

Timeout Boundaries

TimeoutPhase coveredPhase not covered
timeout(Duration)One request attempt until send() returns, including redirects, response headers, and body uploadRetry backoff, later retry attempts, and response body reads after send() returns
connect_timeout(Duration)Pool coordination, DNS, TCP or QUIC setup, every proxy hop, TLS, and HTTP/2 or HTTP/3 establishment on a pool missPooled request dispatch, request upload, response headers after dispatch, and response body reads
read_timeout(Duration)Gaps between response body chunksWaiting for response headers and request upload
write_timeout(Duration)Gaps while uploading request body chunksWaiting for response headers and response body reads

Per-request timeout setters override client defaults. no_timeout() disables a client-level overall request timeout for one request, while phase-specific timeouts still apply if configured on that request. When retries are enabled, this timeout applies per attempt; it does not cap total wall-clock time across backoff sleeps and later attempts. Use read_timeout() to bound stalled response body reads after send() returns.

RequestBuilderSend / RequestBuilderLocal

Fluent builder for configuring a single request. RequestBuilderSend is returned by HttpEngineSend methods; RequestBuilderLocal is returned by HttpEngineLocal methods. Both implement RequestBuilderExt. Fluent setters that cannot fail immediately record the error and return it from build() or send().

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);
}

Typed HeaderValue metadata is preserved. If a request header value is marked sensitive with set_sensitive(true), aioduct strips that header on cross-origin redirects in addition to the built-in Authorization, Cookie, and Proxy-Authorization stripping.

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")]);

// Multipart form-data
// Generated boundaries are RFC 2046-safe and at most 70 bytes.
// let rb = client.post("http://example.com").unwrap()
//     .multipart(aioduct::Multipart::new().text("field", "value"));
//
// Custom boundary (validated, 1-70 RFC 2046 chars) and subtype:
// let form = aioduct::Multipart::new()
//     .with_boundary("WebKitFormBoundaryABC123")?   // Result
//     .subtype("mixed")?                            // multipart/mixed
//     .text("field", "value");

// 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
    .connect_timeout(Duration::from_secs(2)) // per-request connection timeout
    .read_timeout(Duration::from_secs(30))   // per-request response read-gap timeout
    .write_timeout(Duration::from_secs(10))  // per-request upload timeout
    .automatic_content_digest(true)          // per-request SHA-256 Content-Digest
    .no_decompression()                      // per-request: skip Accept-Encoding + decoding
    .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
}

Inspecting a builder

Read accessors let you inspect a configured request before sending it (e.g. for logging, signing, or library wrappers). method_ref() returns the method, url() the resolved URL, and headers_ref() the headers added so far (client default headers are merged at send time, so they are not reflected here). Call build() to get the full http::Request without sending.

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
let rb = client.post("http://example.com/api").unwrap()
    .header(http::header::ACCEPT, http::HeaderValue::from_static("application/json"));
assert_eq!(rb.method_ref(), &http::Method::POST);
assert_eq!(rb.url().to_string(), "http://example.com/api");
assert!(rb.headers_ref().contains_key(http::header::ACCEPT));
}

The builders are #[must_use]: a RequestBuilderSend or RequestBuilderLocal that is never sent or built, or an HttpEngineBuilder that is never built, produces a compiler warning.

HTTP Message Signatures

MessageSignatureConfig builds RFC 9421 request signature bases and formats the Signature-Input / Signature headers from caller-provided signature bytes. MessageSignature parses existing signature fields by label and rebuilds the request or response signature base for caller-owned verification. The context helpers support caller-supplied trailer field components with ;tr. MessageSignatureVerificationPolicy applies request verification policy checks before invoking caller-owned cryptographic verification. When body bytes are attached to verification contexts, the policy also verifies covered SHA-256 Content-Digest fields before calling the verifier. The helpers are portable and do not choose a cryptographic algorithm.

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, Method, Uri};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let headers = HeaderMap::new();

let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::method())
    .component(MessageSignatureComponent::authority())
    .created(1_618_884_473)
    .key_id("test-key");

let base = config.signature_base(&Method::GET, &target_uri, &request_target, &headers)?;
let signature = sign_with_your_key(base.as_bytes());
let signature_headers = config.headers_from_signature(signature)?;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Response bases can cover @status, response fields, and selected related request components with ;req:

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, Method, StatusCode, Uri};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let request_headers = HeaderMap::new();
let response_headers = HeaderMap::new();

let config = MessageSignatureConfig::new("reqres")?
    .component(MessageSignatureComponent::status())
    .component(MessageSignatureComponent::method().related_request())
    .created(1_618_884_479);

let base = config.request_response_signature_base(
    &Method::POST,
    &target_uri,
    &request_target,
    &request_headers,
    StatusCode::OK,
    &response_headers,
)?;
let _ = base;
Ok(())
}
}

Use MessageSignatureRequestContext::with_trailers(...) or MessageSignatureResponseContext::with_trailers(...) with signature_base_for_request_context(), response_signature_base_for_context(), or request_response_signature_base_for_context() when a signature covers caller-supplied trailer fields with ;tr.

Native clients can also sign each finalized request attempt automatically:

#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig, TokioClient};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let config = MessageSignatureConfig::new("sig1")?
    .component(MessageSignatureComponent::method())
    .component(MessageSignatureComponent::authority())
    .key_id("test-key");

let client = TokioClient::builder()
    .message_signature(config, |base: &[u8]| Ok(sign_with_your_key(base)))
    .build()?;
let _ = client;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

Automatic signing runs after default headers, cookies, cache validators, middleware, digest-auth retry headers, forwarding request rewrites, and framing cleanup. When configured, it replaces only its configured label in Signature-Input and Signature on every native dispatch attempt.

Use message_signature_async(...) for send-runtime signers that return Send futures, or message_signature_async_local(...) for local-runtime signers whose future does not need to be Send. Async signers receive an owned MessageSignatureBase, so request/header borrows are not held across the signer await point.

Forward builders can sign the downstream response with response_message_signature(...), response_message_signature_async(...), or response_message_signature_async_local(...). Forward response signing runs after upstream response hop-by-hop cleanup and on_response, preserves unrelated signature labels, replaces its configured label, and uses the inbound request snapshot for related-request components. Use downstream_target_uri(...) when an origin-form inbound request needs related-request @scheme, @authority, or @target-uri coverage.

Forward builders can also call response_content_digest(max_bytes) to buffer the downstream response body up to a caller-supplied cap and insert SHA-256 Content-Digest before response signing. Existing Content-Digest fields are preserved without buffering, bodyless responses such as HEAD, 204, 205, and 304 are skipped, and bodies over the cap fail closed.

Use automatic_content_digest(true) on the client builder or a request builder to insert Content-Digest: sha-256=:...: for buffered native request bodies that do not already have Content-Digest. The header is generated after middleware and before automatic signing, so signatures covering content-digest cover the generated value. Requests without a configured body are left unchanged. Streaming and middleware-replaced bodies are not buffered; provide Content-Digest explicitly for those requests. Use sha256_content_digest_value(...) for an in-memory body, or sha256_content_digest_value_from_digest(...) after hashing a streaming body out-of-band.

AcceptSignature parses and formats RFC 9421 Accept-Signature negotiation fields. AcceptSignatureFulfillment turns accepted entries into concrete MessageSignatureConfig values after validating the target shape and requested metadata:

#![allow(unused)]
fn main() {
use aioduct::{
    AcceptSignature, AcceptSignatureEntry, AcceptSignatureFulfillment,
    MessageSignatureComponent,
};
use http::{HeaderMap, StatusCode};
fn example(mut headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let accept = AcceptSignature::new().entry(
    AcceptSignatureEntry::new("sig1")?
        .component(MessageSignatureComponent::status())
        .created()
        .key_id("test-key"),
);

let configs = accept.response_signature_configs(
    &AcceptSignatureFulfillment::new()
        .created(1_618_884_500)
        .key_id("test-key"),
)?;
let signature_headers = configs[0].sign_response(
    StatusCode::OK,
    &headers,
    &|base: &[u8]| Ok(sign_with_your_key(base)),
)?;
signature_headers.insert_into(&mut headers)?;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}

AcceptSignature::from_headers() parses combined field values. The fulfillment helpers do not choose keys or algorithms and do not automatically decide which requests to honor; callers still generate timestamps, run cryptography, and attach Signature-Input / Signature fields.

For verification, configure a policy, then pass the selected label, rebuilt base, signature bytes, and metadata to your own verifier:

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignatureComponent, MessageSignatureVerificationInput,
    MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, Uri};
fn example(headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;

let policy = MessageSignatureVerificationPolicy::new()
    .required_component(MessageSignatureComponent::method())
    .accepted_algorithm("ed25519")
    .accepted_key_id("test-key")
    .validation_time(1_618_884_500)
    .max_age(300);

policy.verify_request(
    &headers,
    "sig1",
    &Method::GET,
    &target_uri,
    &request_target,
    &|input: MessageSignatureVerificationInput<'_>| {
        Ok(verify_with_your_key(
            input.signature_base(),
            input.signature(),
            input.params(),
        ))
    },
)?;
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8], _: &aioduct::MessageSignatureParams) -> bool {
    true
}
}

If the selected signature covers content-digest and you have the body bytes, use MessageSignatureRequestContext::new(...).with_body(body) with verify_request_context(...). For responses, attach bytes with MessageSignatureResponseContext::new(...).with_body(body). Mismatched, malformed, or unsupported digest fields fail before the verifier callback runs; contexts without body bytes keep the previous signature-only behavior. If the selected signature covers trailer fields with ;tr, attach trailer maps with with_trailers(...); missing trailer maps fail before the verifier callback runs.

Response verification uses borrowed context values so signatures can cover both the response and selected related request components:

#![allow(unused)]
fn main() {
use aioduct::{
    MessageSignatureComponent, MessageSignatureRequestContext,
    MessageSignatureResponseContext, MessageSignatureVerificationInput,
    MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, StatusCode, Uri};
fn example(request_headers: HeaderMap, response_headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let request = MessageSignatureRequestContext::new(
    &Method::POST,
    &target_uri,
    &request_target,
    &request_headers,
);
let response = MessageSignatureResponseContext::new(StatusCode::OK, &response_headers);

let policy = MessageSignatureVerificationPolicy::new()
    .required_component(MessageSignatureComponent::status())
    .required_component(MessageSignatureComponent::method().related_request())
    .validation_time(1_618_884_500);

policy.verify_request_response(
    request,
    response,
    "sig1",
    &|input: MessageSignatureVerificationInput<'_>| {
        Ok(verify_with_your_key(
            input.signature_base(),
            input.signature(),
            input.params(),
        ))
    },
)?;
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8], _: &aioduct::MessageSignatureParams) -> bool {
    true
}
}

If the selected signature includes created or expires, set validation_time(); otherwise the policy fails closed with MissingValidationTime instead of silently accepting stale metadata.

MessageSignature::verify_response() and verify_request_response() apply the same policy to an already parsed signature.

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(())
}
}

Error Handling

Async send() returns SendError on failure. It keeps the redacted request URL next to the underlying Error, exposes helpers such as is_timeout(), is_connect(), is_status(), and status(), and implements source() so standard error-chain traversal works. Error::root_cause() and SendError::root_cause() return the deepest source, and display output includes hidden nested causes for boxed TLS or catch-all errors when the outer message would otherwise omit the useful detail.

Timeout helpers distinguish the configured phases:

ErrorTypical source
TimeoutOverall request deadline from timeout()
ConnectTimeoutConnection establishment deadline from connect_timeout()
ReadTimeoutGap between response body chunks from read_timeout()
WriteTimeoutGap between request body chunks from write_timeout()

Reading the body of an error response

error_for_status() turns a 4xx/5xx into Error::Status(code) and does not capture the body, so the error value stays cheap. When you need the error payload (an API’s JSON error message, for example), read it as a separate stage: status() is a synchronous, non-consuming check, so gate on it and then read the body yourself.

#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com/api")?.send().await?;
if resp.status().is_client_error() || resp.status().is_server_error() {
    let status = resp.status();
    let body = resp.text().await?; // read the error body as its own stage
    eprintln!("server said {status}: {body}");
    return Ok(());
}
let body = resp.text().await?; // success path
let _ = body;
Ok(())
}
}

error_for_status_ref() borrows instead of consuming, so you can check status without giving up ownership of the response, then read the body on either branch. Status and body are intentionally decoupled, and reading the body consumes the response (no implicit buffering), matching reqwest’s and aiohttp’s model.

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(())
}
}

Blocking Client

With the blocking feature enabled, BlockingTokioClient wraps TokioClient for synchronous callers. BlockingResponse exposes the same buffered consumers as async responses and native response metadata accessors before body consumption. Blocking request builders forward the common buffered request-body and per-request behavior controls, including body(), form(), timeout(), read_timeout(), connect_timeout(), no_decompression(), query(), and version().

#![allow(unused)]
fn main() {
#[cfg(all(feature = "blocking", feature = "tokio"))]
fn example() -> Result<(), aioduct::Error> {
use aioduct::{BlockingTokioClient, TokioClient};

let client = BlockingTokioClient::new(TokioClient::new());
let mut resp = client.post("http://example.com/")?
    .form(&[("name", "alice")])
    .no_decompression()
    .send()?;
resp.headers_mut().insert("x-local", "yes".parse().unwrap());
let body = resp.bytes()?;
Ok(())
}
}

Portable Traits

These traits provide a common interface across client types. Implementations must either apply each request-builder option or fail explicitly when the request is sent; unsupported options are not silently ignored.

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) and request headers whose HeaderValue is marked sensitive are automatically stripped when redirecting to a different origin.

Disable with .max_redirects(0) on the builder.

Request Lifecycle Observer

The RequestObserver trait provides real-time callbacks at every connection phase transition with monotonic timestamps and diagnostic data. Use it for per-request tracing, load testing metrics, or custom instrumentation.

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

#[derive(Clone)]
struct MetricsObserver { /* atomic counters, channels, etc. */ }

impl RequestObserver for MetricsObserver {
    fn on_event(&self, event: &RequestEvent) {
        match &event.phase {
            RequestPhase::DnsResolved { addrs, duration } => { /* ... */ }
            RequestPhase::TlsHandshakeComplete { duration, alpn_protocol, .. } => { /* ... */ }
            RequestPhase::ResponseComplete { status, total_duration, .. } => { /* ... */ }
            _ => {}
        }
    }

    fn on_connection_event(&self, event: &ConnectionEvent) {
        // Connection-level metrics fire when a connection is checked back into
        // the pool or closed. The metrics include protocol, remote address,
        // approximate bytes sent/received, connection age, requests served, and
        // whether the connection was closed instead of returned to the pool.
    }
}

let client = TokioClient::builder()
    .observer(MetricsObserver { /* ... */ })
    .build()?;
}

RequestPhase Variants

PhaseKey FieldsFires When
StartedRequest execution begins
PoolCheckoutCompleteoutcome, blocked_durationPool lookup finishes
DnsResolvedaddrs, durationDNS resolution completes
TcpConnectedremote_addr, duration, protocolTCP connection established
TlsHandshakeCompleteduration, alpn_protocol, peer_certificate_derTLS negotiation done
RequestSentduration, headersFinalized request is about to be handed to the protocol sender
ResponseStartedwaiting_durationTTFB — first response byte received
ResponseCompletestatus, protocol, total_durationResponse headers complete
Redirectedstatus, from, toA redirect was followed
Retryingreason, attempt, max_retries, backoffA retry is about to be attempted
Failederror, retry, elapsedRequest failed with an error
BytesTransferreddirection, chunk_bytes, cumulative_bytesPer-chunk (body streaming)
TransferCompletedirection, total_bytes, throughput_bytes_per_secTransfer in one direction finished
TransferAborteddirection, bytes_transferred, errorTransfer aborted mid-stream
TrailersReceivedheadersHTTP trailers received after body

RetryKind (None / StaleConnection / Explicit) indicates whether and how a failed request will be retried.

Phases that are skipped (DNS for pool hits, TLS for plain HTTP) simply don’t fire.

Trailers

HTTP trailers are optional header fields sent after the body in chunked transfer encoding. They are available through three channels:

Via the bytes stream

The simplest approach: drain the body with into_bytes_stream() and call trailers() once the stream is exhausted. Trailers are captured automatically as non-data frames are consumed.

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

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

let mut stream = resp.into_bytes_stream();
while let Some(chunk) = stream.next().await {
    let _bytes = chunk?;
    // process body data …
}

// Trailers are available after the stream is fully consumed
if let Some(trailers) = stream.trailers() {
    for (name, value) in trailers.iter() {
        println!("trailer {name}: {value:?}");
    }
}
}

Via the raw body frame stream

For lower-level control, iterate the body frames directly:

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

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

let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
    let frame = frame?;
    if frame.is_trailers() {
        let trailers = frame.into_trailers().unwrap();
        for (name, value) in trailers.iter() {
            println!("{name}: {value:?}");
        }
    }
}
}

Via the request observer

The RequestObserver fires a TrailersReceived phase when trailers arrive, with all trailer header fields.

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
// PoolLimitKind          — what pool limit was reached
// Error::Timeout         — request timed out
// Error::ConnectTimeout  — connection establishment timed out
// Error::ReadTimeout     — reading response timed out
// Error::WriteTimeout    — writing request body timed out
// Error::InvalidUrl(_)   — URL parse or scheme errors
// Error::InvalidHeader(_) — header name or value errors
// Error::Unsupported(_)  — runtime or transport does not support an option
// 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_write_timeout()Returns true if the error is an upload timeout
is_connect()Returns true if the error occurred during connect
is_pool_limit()Returns true if the error is a pool limit (client-side backpressure)