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
- No hyper-util — custom executor and IO adapters directly against
hyper::rttraits. ~50 lines each, zero legacy baggage. - No default runtime — the core crate is pure types, traits, and logic. Opt into a runtime via feature flags.
- No default TLS — plain HTTP works out of the box. Enable
rustlsfor HTTPS. - Runtime-agnostic core —
HttpEngineSend<R, C>andHttpEngineLocal<R, C>are generic over runtime and connector traits. All pool, TLS, and HTTP logic works with any conforming runtime. - 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
| Feature | reqwest | aioduct |
|---|---|---|
| hyper version | 1.x via hyper-util legacy | 1.x direct |
| hyper-util | Required | Not used |
| Runtime | tokio only | tokio / smol / compio / wasm |
| TLS | rustls or native-tls | rustls (native-tls reserved) |
| HTTP/3 | Experimental | Experimental |
| io_uring | No | Via compio feature |
| Connection pool | hyper-util legacy | Custom, built for h1/h2/h3 |
| Cookie jar | Yes | Yes |
| SSE streaming | No (manual) | Built-in |
| Rate limiting | No | Built-in |
| HTTP caching | No | Built-in |
| Middleware | Via tower | Built-in + tower |
| Happy Eyeballs | No | RFC 6555 |
| Digest auth | No | Built-in |
| Bandwidth limiter | No | Built-in |
| Netrc | No | Built-in |
| Request timings | No | Observer |
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
| Feature | Dependencies | Stability | Description |
|---|---|---|---|
tokio | tokio | Stable | Tokio async runtime |
smol | smol, async-io, futures-io | Stable | Smol async runtime |
compio | compio-runtime, async-io | Experimental | Compio runtime (io_uring / IOCP) |
wasm | wasm-bindgen, web-sys, js-sys | Experimental | Compatible browser/worker Fetch runtime |
wasi-p2 | wasi | Experimental | WASI Preview 2 guest HTTP client |
wasmtime | wasmtime, wasmtime-wasi, wasmtime-wasi-http | Experimental | Wasmtime host-side WASI HTTP adapter |
rustls | rustls, webpki-roots, rustls-pemfile | Stable | TLS backend via rustls; requires exactly one rustls provider |
rustls-ring | rustls ring provider | Stable | Ring crypto provider for rustls |
rustls-aws-lc-rs | rustls AWS-LC provider | Stable | AWS-LC crypto provider for rustls |
rustls-native-roots | rustls-native-certs | Stable | Use OS certificate store with either rustls provider |
json | serde, serde_json, serde_urlencoded | Stable | JSON request/response helpers |
charset | encoding_rs, mime | Stable | Charset decoding for response text |
gzip | flate2 | Stable | Gzip response decompression |
deflate | flate2 | Stable | Deflate response decompression |
brotli | brotli | Stable | Brotli response decompression |
zstd | zstd | Stable | Zstd response decompression |
blocking | selected native runtime | Stable | Synchronous wrapper for Tokio, smol, or compio clients |
hickory-dns | hickory-resolver, tokio | Stable | DNS resolution via hickory |
doh | hickory-resolver (https) | Stable | DNS-over-HTTPS (implies hickory-dns) |
dot | hickory-resolver (tls) | Stable | DNS-over-TLS (implies hickory-dns) |
tower | tower-service, tower-layer | Stable | Tower Service/Layer integration |
tracing | tracing | Stable | Tracing spans for HTTP requests |
otel | opentelemetry, opentelemetry-http | Stable | OpenTelemetry middleware |
precise-timing | none | Stable | Use std::time::Instant instead of the default coarse clock for sub-millisecond observer and timeout measurements |
http3 | h3, quinn | Experimental | HTTP/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:
hyper1.x — HTTP/1.1 and HTTP/2 protocol enginehttp— Standard HTTP types (Method, StatusCode, HeaderMap, etc.)http-body-util— Body combinators for hyperbytes— Zero-copy byte bufferspin-project-lite— Safe pin projectionsthiserror— Error derive macrosbase64— Base64 encoding for basic authpercent-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/AsyncWritetohyper::rt::Read/hyper::rt::Write. Each is ~50 lines of unsafe pin projection. - HTTP/2 task executors: Separate internal
PollExecutorandCompletionExecutorimplementations delegate to the active runtime’sspawn_sendorspawn_localoperation. Both usePhantomData<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 areSend(tokio, smol). The connector produces streams that areSend, 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 aSendstream.ConnectorLocal:'static, connects asynchronously, returns a!Sendstream.
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 directlyWithTimeout { future, sleep }— polls both; if sleep completes first, returnsError::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 aSendfuture 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!Sendfuture 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();
}
TokioRuntimecreates a current-thread runtime forblock_on, rejects blocking use from inside an active Tokio runtime, and implementsRuntimePollwithtokio::spawnplustokio::time::sleep.tokio_rt::TcpConnectorimplementsConnectorSendusingtokio::net::TcpStream. SetsTCP_NODELAYby default.- The
TokioIoadapter bridges tokio’sAsyncRead/AsyncWriteto hyper’srt::Read/rt::Write.
SmolRuntime + TcpConnector
Enabled with features = ["smol"].
#![allow(unused)]
fn main() {
use aioduct::SmolClient;
let client = SmolClient::new();
}
SmolRuntimeimplementsRuntimeCompletionandRuntimePollusingsmol::block_on,smol::spawn, andasync_io::Timer.smol_rt::TcpConnectorimplementsConnectorSendusingsmol::net::TcpStream.- The
SmolIoadapter bridgesfutures_io::AsyncRead/AsyncWriteto 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.
CompioRuntimecreates acompio_runtime::Runtimeforblock_on, usescompio_runtime::spawnfor local tasks, and wrapsasync_io::Timerfor the shared runtime sleep contract.compio_rt::TcpConnectorimplementsConnectorLocal. Streams are!Sendsince 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:
- Implement
RuntimeCompletionandRuntimePollfor your runtime marker type. - Implement
ConnectorSendfor a connector struct that establishes TCP connections using your runtime’s networking primitives. - Provide an IO adapter that implements
hyper::rt::Readandhyper::rt::Writeby 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:
| Marker | Meaning |
|---|---|
| ✓ | 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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| GET, POST, HEAD, PUT, DELETE, PATCH | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
| Custom method | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
All backends support the six standard methods plus an arbitrary-method
request() entry point through WasmClient or WasiClient.
Request Headers
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| Set (per-request) | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
| Override (batch) | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
| Default headers | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-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
| Feature | tokio | smol | compio | wasm | wasi-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 generation | ✓ tokio | ✓ smol | ✓ compio | ✗ manual headers only | ✗ manual headers only |
Forward bounded response Content-Digest generation | ✓ tokio | ✓ smol | ✓ compio | ✗ no native forwarding | ✗ no native forwarding |
| Sync automatic request signing | ✓ tokio | ✓ smol | ✓ compio | ✗ manual headers only | ✗ manual headers only |
| Async automatic request signing | ✓ Send future | ✓ Send 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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| Bearer token | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
| Basic auth | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
query() (string pairs) | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
query_serde() (serialize) | ✓ json | ✓ json | ✓ json | ✗ | ✗ |
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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
Buffered (body()) | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ✓ wasi-p2 |
JSON (json()) | ✓ json | ✓ json | ✓ json | ✓ json | ✓ json |
| Streaming | ✓ | ✓ | ✓ | ✗ | ✗ |
| Multipart/form-data | ✓ | ✓ | ✓ | ✗ | ✗ |
| Form (urlencoded string pairs) | ✓ | ✓ | ✓ | ✓ wasm | ✓ wasi-p2 |
| Form (serializable value) | ✓ json | ✓ json | ✓ json | ✗ | ✗ |
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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
bytes() | ✓ | ✓ | ✓ | ✓ wasm | ✓ wasi-p2 |
text() | ✓ | ✓ | ✓ | ✓ wasm | ✓ wasi-p2 |
json() | ✓ json | ✓ json | ✓ json | ✓ json | ✓ json |
Streaming (into_bytes_stream()) | ✓ tokio | ✓ smol | ✓ compio | ✓ wasm | ⚠ 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
| Feature | tokio | smol | compio | wasm | wasi-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
| Feature | tokio | smol | compio | wasm | wasi-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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| Request timeout | ✓ tokio | ✓ smol | ✓ compio | ⚠ AbortController [4] | ⚠ WASI-mapped [5] |
| Connect timeout | ✓ tokio | ✓ smol | ✓ compio | ✗ explicit error [4] | ⚠ WASI-mapped [5] |
| Read timeout | ✓ tokio | ✓ smol | ✓ compio | ✗ 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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| HTTP proxy | ✓ | ✓ | ✓ | ✗ | ✗ |
| HTTPS proxy | ✓ rustls | ✓ rustls | ✓ rustls | ✗ | ✗ |
| 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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| Custom resolver | ✓ hickory-dns | ✓ hickory-dns | ✓ hickory-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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| rustls | ✓ rustls | ✓ rustls | ✓ rustls | ⚠ browser-managed [8] | ⚠ WASI-managed [9] |
| Platform-native certs | ✓ rustls-native-roots | ✓ rustls-native-roots | ✓ rustls-native-roots | — | — |
| Client certificates | ✓ rustls | ✓ rustls | ✓ rustls | ✗ | ✗ |
[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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| Keep-alive | ✓ tokio | ✓ smol | ✓ compio | ⚠ browser-managed [10] | ⚠ WASI-managed [11] |
| max_idle_per_host | ✓ tokio | ✓ smol | ✓ compio | ⚠ browser-managed [10] | ⚠ WASI-managed [11] |
| idle_timeout | ✓ tokio | ✓ smol | ✓ compio | ⚠ browser-managed [10] | ⚠ WASI-managed [11] |
| max_lifetime | ✓ tokio | ✓ smol | ✓ compio | ⚠ 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
| Feature | tokio | smol | compio | wasm | wasi-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
| Feature | tokio | smol | compio | wasm | wasi-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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| gzip | ✓ gzip | ✓ gzip | ✓ gzip | ⚠ browser-managed [12] | ✗ |
| brotli | ✓ brotli | ✓ brotli | ✓ brotli | ⚠ browser-managed [12] | ✗ |
| zstd | ✓ zstd | ✓ zstd | ✓ zstd | ⚠ browser-managed [12] | ✗ |
| deflate | ✓ deflate | ✓ deflate | ✓ deflate | ⚠ 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
| Feature | tokio | smol | compio | wasm | wasi-p2 |
|---|---|---|---|---|---|
| HTTP/2 | ✓ | ✓ | ✓ | ⚠ browser-managed [13] | ⚠ WASI-managed [14] |
| HTTP/2 config tuning | ✓ tokio | ✓ smol | ✓ compio | ✗ | ✗ |
| HTTP/3 | ✓ http3 | ✗ | ✗ | ⚠ 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
WasmClientliterally cannot influence them. -
Cookies and redirects: The browser processes
Set-Cookieand follows redirects automatically as part of the fetch spec. Browser Fetch filters forbidden response headers (includingSet-Cookie) from theHeadersobject, soSet-Cookieis not readable fromWasmClient::headers()orCookieJarintegration. 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
WasmClientemulates timeouts usingAbortController+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-Encodingand transparently decompress responses. The client receives already-decompressed bytes. Thedecompress.rsmodule 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-handlerdoes not include redirect following or cookie management. These must be implemented in the client, but the currentWasiClienthas not yet wired in the portableRedirectPolicyorCookieJartypes. -
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
InputStreamusesblocking_read, which is synchronous. This is consistent with the WASI Preview 2 model but means theWasiBodyStreamdoes 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 area | Native (tokio/smol/compio) | WASM (host Fetch) | WASI-P2 |
|---|---|---|---|
| Request/response basics | Fully supported | Fully supported | Fully supported |
| Streaming body | Full async streaming | Response streaming via ReadableStream | Sync-only body stream |
| Redirect, cookie, retry, middleware | Integrated | Not applicable / platform-managed | Types available, not integrated |
| TLS, DNS, pooling, HTTP version | Configurable | Host-managed | WASI runtime-managed |
| Proxy | Full support | Not available | Not available |
| Compression | Per-codec cfg features | Host-managed | Not 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 transport | Supported by aioduct::wasmtime | Notes |
|---|---|---|
TokioClient | Yes | Explicit tokio feature; can be default-built after feature selection |
SmolClient | Yes | Explicit smol feature and transport |
CompioClient | Yes | Explicit 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-*, orproxy-*, 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:
RustlsConnectorwraps arustls::ClientConfig(with ALPN protocolsh2andhttp/1.1)- On connect, a
TlsStream<S>is created with the underlying TCP stream and arustls::ClientConnection - The handshake drives
read_tls/write_tlshelper functions that wrap the async stream as synchronousstd::io::Read/Write, usingWouldBlockfor flow control - 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→ useshyper::client::conn::http2::handshakehttp/1.1(or no ALPN) → useshyper::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
| Runtime | TLS provider | Configuration surface |
|---|---|---|
| Tokio | rustls | Builder TLS methods and custom RustlsConnector |
| smol | rustls | Same TLS configuration as Tokio |
| compio | rustls | Same TLS configuration through the local client path |
| blocking | Wrapped native client | Inherits the configured async client TLS behavior |
| wasm | Browser-managed | Certificate verification, SNI, ALPN, and roots are controlled by the browser |
| wasi-p2 | Host-managed | Certificate 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
rustlsbackend 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
- 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.
- Reserve: If no reusable connection is available and
pool_max_active_per_hostis 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 typedPoolLimitKind::MaxActivePerHosterror. - Send: The request is sent on the connection (either reused or freshly established).
- 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_lifetimeare 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
| Option | Default | Description |
|---|---|---|
pool_idle_timeout | 90s | How long an idle connection is kept before eviction |
pool_max_lifetime | none | Maximum connection age before it stops being reused |
pool_max_idle_per_host | 10 | Maximum idle connections per full pool key |
pool_max_active_per_host | unlimited | Maximum checked-out handles and fresh connection attempts per pool key; 0 disables the cap |
pool_max_active_streams_per_connection | unlimited | Maximum 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
| Field | Type | Meaning |
|---|---|---|
checkout_hits | u64 | Checkouts that found an idle connection in the pool |
checkout_coalesced_hits | u64 | Checkouts reused via SAN-based coalescing (always 0 on local engines) |
checkout_misses | u64 | Requests that exhausted all pool paths and required a fresh connection |
stale_reuse_retries | u64 | Connections detected as stale mid-request and transparently retried |
idle_timeout_evictions | u64 | Connections evicted due to idle timeout expiry |
max_lifetime_evictions | u64 | Connections evicted due to exceeding their maximum lifetime |
checkout_not_ready_evictions | u64 | Connections discarded at checkout because is_ready() returned false |
capacity_evictions | u64 | Connections evicted because the per-host idle queue was at capacity |
idle_pool_entries | usize | Idle pool handles across all hosts (current) |
checked_out_pool_handles | usize | Checked-out pool handles across all hosts (current) |
hosts | Vec<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
- When a new request has no pooled connection for its origin, the pool scans existing h2/h3 connections.
- 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.
- This avoids a redundant TLS handshake and TCP/QUIC connection for hosts that share infrastructure (e.g.,
api.example.comandcdn.example.comon 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
rustlsfeature (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_STREAMand qualifying GOAWAY boundaries may prove that a stream was not processed. HTTP/3H3_REQUEST_REJECTEDcan provide equivalent evidence, but upstreamh3does 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
| Runtime | Dispatch coverage |
|---|---|
| Tokio | Send path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, HTTP/3, TLS, and proxies |
| smol | Shared Send path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, TLS, and proxies |
| compio | Local path, HTTP/1.0 semantics, HTTP/1.1, HTTP/2, TLS, and proxies |
| blocking | Guarantees inherited from the wrapped native client |
| wasm | Browser-managed Fetch transport |
| wasi-p2 | Host-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:
| Field | Type | Description |
|---|---|---|
event | Option<String> | Event type (from event: field) |
data | String | Event payload (joined with \n for multi-line) |
id | Option<String> | Event ID (from id: field) |
retry | Option<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:
SseEvent { event: Some("greeting"), data: "hello", id: None, retry: None }SseEvent { event: None, data: "line1\nline2", id: None, retry: None }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
| Field | Type | Default | Description |
|---|---|---|---|
max_retries | u32 | 3 | Maximum number of retry attempts |
initial_backoff | Duration | 100ms | Delay before the first retry |
max_backoff | Duration | 30s | Upper bound on backoff delay |
backoff_multiplier | f64 | 2.0 | Multiplier applied to backoff each attempt |
retry_on_status | bool | true | Whether to retry on retryable HTTP statuses |
budget | Option<RetryBudget> | None | Token-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_statusis true) - 429 Too Many Requests — rate limiting responses (when
retry_on_statusis true) - 408 Request Timeout and 425 Too Early — retried for idempotent requests (when
retry_on_statusis 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 bymax_retriesand anybudget. 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
| Policy | Behavior |
|---|---|
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
Locationheader carries its own fragment, that fragment wins. - If
Locationhas 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
| Method | Use Case | Memory |
|---|---|---|
resp.bytes() | Small responses, read all at once | Entire body in memory |
resp.text() | Small text responses | Entire body in memory |
resp.into_bytes_stream() | Large downloads, progress tracking | One chunk at a time |
resp.into_sse_stream() | Server-Sent Events | One 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
Bytesbuffer (used by.body(),.json(),.form(),.multipart()) - Streaming — a
RequestBodySendthat 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) |
|---|---|---|
| Memory | Entire body in RAM | Chunk at a time |
| Retry | Full retry support | First attempt only |
| Redirect (307/308) | Body preserved | Body 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
- HEAD request — checks
Accept-Ranges: bytesandContent-Lengthheaders - Range splitting — divides the file into N equal-sized chunks
- Parallel fetch — spawns concurrent
Rangerequests via the runtime - 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
| Method | Default | Description |
|---|---|---|
.chunks(n) | 4 | Number of parallel range requests |
Result
ChunkDownloadResult contains:
total_size: u64— the total file size in bytesdata: 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-Lengthheader - Support
Range: bytes=start-endrequests and respond with206 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()):
- The first request to a new origin goes over TCP (HTTP/1.1 or HTTP/2 via ALPN).
- If the response includes an
Alt-Svcheader advertisingh3(e.g.,Alt-Svc: h3=":443"; ma=86400), the client caches this. - Subsequent requests to the same origin use QUIC/HTTP/3 instead of TCP.
- The cache respects
ma(max-age) — entries expire after the specified duration (default 24 hours). Alt-Svc: clearremoves 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):
- 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.
- HTTP requests (plain) continue to use TCP-based HTTP/1.1 or HTTP/2 as usual.
- 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-freeOPTIONS *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-completedsend()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-RTT —
h3_zero_rtt(true)is retained for API compatibility, but building that client returnsError::Unsupported. Validating remembered peer SETTINGS is required before this can be safe. - GOAWAY-based replay — upstream
h3reportsRemoteClosingwithout 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. ExplicitH3_REQUEST_REJECTEDstream 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
tokiofeature and an active Tokio runtime when HTTP/3 is enabled. The genericRuntimePollbuilder 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
- When a response contains
Set-Cookieheaders, the jar stores each cookie keyed by domain - On subsequent requests, matching cookies are sent in the
Cookieheader - Cookies with the
Secureflag are only sent over HTTPS - If a response sets a cookie with the same name, it replaces the existing one
- Cookies with
Max-Age=0or a pastExpiresdate are removed from the jar - The
Pathattribute is respected — cookies are only sent for matching request paths - Domain matching supports subdomains — a cookie for
example.comis sent tosub.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
}
Without Cookie Jar
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());
}
}
Cookie Accessors
| Method | Return Type | Description |
|---|---|---|
name() | &str | Cookie name |
value() | &str | Cookie value |
domain() | Option<&str> | Domain attribute (defaults to request domain) |
path() | Option<&str> | Path attribute |
secure() | bool | Whether the cookie requires HTTPS |
http_only() | bool | Whether 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 (requiresSecureflag)
#![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()),
}
}
}
Cookie Prefixes
aioduct enforces cookie prefix validation per RFC 6265bis:
__Host-— requiresSecure, exact domain match (noDomainattribute pointing elsewhere), andPath=/__Secure-— requiresSecureflag
Cookies that fail prefix validation are silently rejected.
Cookie Attributes
Domain Matching
Cookies use RFC-compliant domain matching with subdomain support:
- A cookie stored for
example.commatches requests toexample.comandsub.example.com - A cookie stored for
sub.example.comdoes not matchexample.comorother.example.com - Leading dots in the
Domainattribute are stripped (Domain=.example.combecomesexample.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=0or a negative value is received- An
Expiresdate 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:
| Directive | Behavior |
|---|---|
max-age=N | Response is fresh for N seconds |
s-maxage=N | Shared cache max-age (takes precedence over max-age) |
no-cache | Always revalidate before serving |
no-store | Never store the response |
must-revalidate | Must revalidate once stale |
private | Response 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:
- If the cached response has an
ETag, the request includesIf-None-Match - If the cached response has a
Last-Modifieddate, the request includesIf-Modified-Since - A
304 Not Modifiedresponse 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());
}
| Method | Default | Description |
|---|---|---|
max_entries() | 256 | Maximum 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
- When an HTTPS response contains a
Strict-Transport-Securityheader, the domain and its policy are recorded in the store - On subsequent requests to the same domain over
http://, the URL is transparently upgraded tohttps:// - If the header includes
includeSubDomains, all subdomains of the host are also upgraded - A
max-age=0directive 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 HTTPSincludeSubDomains— also apply the policy to all subdomains
Subdomain Matching
When includeSubDomains is set for example.com:
http://example.com→ upgraded tohttps://example.comhttp://api.example.com→ upgraded tohttps://api.example.comhttp://deep.sub.example.com→ upgraded tohttps://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
| Area | Behavior |
|---|---|
| HTTP cache | Opt-in HttpCache follows cacheable methods/statuses, Cache-Control, Expires, validators, Vary, stale-while-revalidate, and stale-if-error. Unsafe methods invalidate matching entries. |
| HSTS | Opt-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 upgrade | Opt-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 safety | Request 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 Signatures | message_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 line | HTTP/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 settings | Client 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
| Component | Source |
|---|---|
@method | Request method, preserving case. |
@scheme | Lowercase target URI scheme. |
@authority | Target URI authority with lowercase host and default http:80 / https:443 ports omitted. |
@request-target | The actual final request URI form. |
@target-uri | The full target URI. |
@path | Target URI path, with an empty path normalized to /. |
@query | Target URI query with a leading ?; absent query signs as ?. |
@status | Response status code with no reason phrase. |
| Header and trailer fields | Lowercase 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 area | Status | Test coverage | Owner | Notes |
|---|---|---|---|---|
| Appendix B.2.3 full request coverage | Supported | appendix_b23_full_coverage_request_base | Current | Covers request derived components, plain fields, Content-Digest as a caller-supplied header, and signature parameters. |
| Appendix B.2.5 HMAC request example | Supported | appendix_b25_hmac_request_base_and_header_formatting | Current | Tests request base and single-label header formatting with caller-supplied signature bytes. It does not test HMAC itself. |
| Appendix B.2.6 Ed25519 request example | Supported | appendix_b26_ed25519_request_base | Current | Tests request base only; Ed25519 signing remains caller-owned. |
| Appendix B.3 TLS-terminating proxy request base | Supported | appendix_b3_tls_terminating_proxy_request_base | Current | Covers proxy-style authority and a long Client-Cert field value. |
| Appendix B.4 request transformations | Supported | appendix_b4_safe_request_transformations_keep_base_stable, appendix_b4_unsafe_request_transformations_change_base | Current | Covers 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 set | Supported | empty_covered_component_set_builds_signature_params_only_base, parsed_signature_accepts_empty_covered_set, verification_policy_allows_empty_covered_component_set | Current | Supports 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-param | Supported | appendix_b22_query_param_request_base | Current | Covers named query parameter parsing, form-style decoding, percent-encoded component identifiers, and missing/duplicate parameter errors. |
Component parameter ;bs | Supported | byte_sequence_header_values_are_signed_as_structured_field_list | Current | Covers Byte Sequence wrapping for caller-supplied header field values. |
Component parameter ;key | Supported | dictionary_key_header_values_are_signed_as_structured_field_members, dictionary_key_missing_malformed_and_duplicate_values | Current | Covers 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 ;sf | Supported | structured_field_header_values_are_signed_with_strict_serialization, parsed_signature_requires_structured_field_type_for_sf_components, verification_policy_applies_required_structured_field_type | Current | Covers 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 ;tr | Supported | response_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_components | Current | Covers 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 bases | Supported | builds_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_signature | Current | Builds 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 ;req | Supported | request_response_signature_base_uses_related_request_components, parsed_signature_rebuilds_response_and_related_request_base, response_signature_rejects_components_from_wrong_context | Current | Routes ;req components to the related request when building response bases and rejects ;req on request targets or without related request context. |
| Multiple signature dictionaries | Supported | insert_into_merges_signature_headers_by_label, automatic_signing_merges_existing_signature_headers_by_label | Current | Generated 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 rebuild | Supported | parsed_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_errors | Current | Parses 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 API | Supported | verification_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_callback | Current | Applies 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 verification | Supported | verification_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_unavailable | Current | Verifies 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 builder | Supported | accept_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_components | Current | Parses and formats requested signature dictionaries, exposes requested metadata, and validates request, response, or request-response target component applicability. |
Accept-Signature fulfillment helpers | Supported | accept_signature_fulfills_response_with_related_request, accept_signature_fulfills_next_request, accept_signature_fulfillment_reports_unfulfillable_requests, accept_signature_allows_ignoring_requests_and_adding_signatures | Current | Converts 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 helpers | Supported | formats_sha256_content_digest, formats_precomputed_sha256_content_digest, inserts_sha256_content_digest | Current | Builds explicit Content-Digest field values from complete body bytes or a precomputed 32-byte SHA-256 digest. |
Buffered automatic Content-Digest generation | Supported | automatic_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_digest | Current | Native 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 generation | Supported | forward_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_response | Current | Native 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 signing | Supported | async_automatic_signing_adds_headers_after_middleware, async_signer_error_aborts_request_before_dispatch, test_compio_async_local_message_signature | Current | Send-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 generation | Future follow-up | Matrix only | Post first pass | Trailer 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 validation | Not in scope | Matrix only | Caller-owned | aioduct 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
| Feature | Codec | Crate |
|---|---|---|
gzip | gzip | flate2 |
deflate | deflate | flate2 |
brotli | br | brotli |
zstd | zstd | zstd |
[dependencies]
aioduct = { version = "0.2.5", features = ["tokio", "rustls", "rustls-ring", "gzip", "brotli"] }
How It Works
When any decompression feature is enabled:
- The client adds an
Accept-Encodingheader to outgoing requests listing the enabled codecs (unless you already set one). - If the response has a matching
Content-Encoding, the body is transparently decompressed. - The
Content-EncodingandContent-Lengthheaders 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
| Scenario | Tokio | smol | compio |
|---|---|---|---|
| One HTTP, HTTPS, or SOCKS proxy | proxy-connect | proxy-connect | proxy-connect |
| Two-hop proxy chain | proxy-chain | proxy-chain | proxy-chain |
Scheme routing, NO_PROXY, and environment credentials | proxy-routing | proxy-routing | proxy-routing |
Proxy Schemes
| Scheme | Constructor | DNS Resolution | Description |
|---|---|---|---|
http:// | ProxyConfig::http() | N/A | HTTP CONNECT proxy |
https:// | ProxyConfig::https() | N/A | TLS-wrapped HTTP CONNECT proxy |
socks4:// | ProxyConfig::socks4() | Local | SOCKS4 proxy |
socks4a:// | ProxyConfig::socks4() | Remote | SOCKS4a proxy (domain sent to proxy) |
socks5:// | ProxyConfig::socks5() | Local | SOCKS5 proxy (client resolves hostnames, sends IP) |
socks5h:// | ProxyConfig::socks5h() | Remote | SOCKS5h 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 requestsHTTPS_PROXY/https_proxy— proxy for HTTPS requestsNO_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
| Runtime | Proxy configuration | Bypass matching | Notes |
|---|---|---|---|
| Tokio | Native stack | NoProxy | HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h |
| smol | Native stack | NoProxy | Same behavior as Tokio for send clients |
| compio | Native stack | NoProxy | Same behavior through local clients |
| blocking | Wrapped native client | Wrapped native client | Inherits the configured async client behavior |
| wasm | Browser-managed | Browser-managed | The browser decides proxy routing and bypass rules |
| wasi-p2 | Host-managed | Host-managed | The WASI host decides proxy routing and bypass rules |
NO_PROXY Rules
The NO_PROXY value is a comma-separated list of patterns:
| Pattern | Matches |
|---|---|
example.com | example.com and *.example.com |
.example.com | *.example.com (subdomains only) |
* | All hosts (disables proxy) |
127.0.0.1 | Exact IP match |
10.0.0.0/8 | IPv4 CIDR match |
2001:db8::/32 | IPv6 CIDR match |
example.com:8080 | Hostname only when the request port is 8080 |
[2001:db8::1]:443 | IPv6 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 hop | Valid second hops |
|---|---|
| HTTP | HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h |
| HTTPS | HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h |
| SOCKS4 or SOCKS4a | HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, SOCKS5h |
| SOCKS5 or SOCKS5h | HTTP, 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:
- Connects to the proxy via TCP
- Sends
CONNECT host:port HTTP/1.1to establish a tunnel - Waits for a successful
2xxresponse from the proxy - Performs TLS handshake through the tunnel
- 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.
| Feature | Tokio / smol / compio | blocking | wasm | wasi-p2 |
|---|---|---|---|---|
| HTTP proxy (CONNECT tunnel) | Yes | Yes, via wrapped async client | Browser-managed | Host-managed |
| HTTPS proxy (TLS to proxy) | Yes | Yes | Browser-managed | Host-managed |
| Origin ECH configuration through HTTPS proxy | Rejected before proxy transport I/O | Rejected before proxy transport I/O | Browser-managed | Host-managed |
| SOCKS4 / SOCKS4a | Yes | Yes | Not available | Not available |
| SOCKS5 / SOCKS5h | Yes | Yes | Not available | Not available |
| Proxy auth (Basic, URI-embedded) | Yes | Yes | Browser-managed | Host-managed |
| Credential resolver | Yes | Yes | Not available | Not available |
| Custom CONNECT headers | Yes | Yes | Not available | Not available |
| System proxy (env vars) | Yes | Yes | Not available | Not available |
| NO_PROXY bypass rules | Yes | Yes | Not available | Not available |
| Custom proxy selection | Yes | Yes | Not available | Not available |
| Proxy chaining (up to 2 hops) | Yes | Yes | Not available | Not available |
| HTTP/3 with proxy (CONNECT tunnel fallback) | Yes | Yes | N/A | N/A |
| Redirect through proxy (auth survives hops) | Yes | Yes | Browser-managed | Host-managed |
| TCP keepalive on proxy tunnel | Yes | Yes | N/A | N/A |
Future Work
| Item | Notes |
|---|---|
| Windows machine-scope system proxy | System proxy currently means environment variables. Windows registry / WinHTTP proxy discovery is planned. |
| NTLM proxy authentication | Only 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 override | Proxy 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.
EnvCredentialResolverapplies 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://orhttps://scheme; SOCKS proxies must usesocks4://,socks4a://,socks5://, orsocks5h://
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
| Method | Description |
|---|---|
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:
- The request is fully built (headers, body, query params applied).
on_requestis called for each middleware in order.- The request is sent over the connection.
- The response is received.
on_responseis called for each middleware in reverse order.- Decompression is applied (if enabled).
- 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
- The client sends the initial request without credentials.
- If the server responds with
401 Unauthorizedand aWWW-Authenticate: Digest ...header, the client parses the challenge. - The client computes the digest response using the MD5 algorithm, the request method, URI, and the server-provided nonce.
- 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
| Feature | Status |
|---|---|
| MD5 algorithm | Supported |
qop=auth | Supported |
opaque parameter | Supported |
Nonce counting (nc) | Supported |
Client nonce (cnonce) | Supported |
| MD5-sess | Not supported |
| SHA-256 | Not supported |
qop=auth-int | Not 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
RandomStatefor 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:
- The bucket starts full — capacity equals
bytes_per_sec. - When the response body is read, each data frame is checked against the bucket.
- If enough tokens are available, the frame is emitted immediately and tokens are consumed.
- 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).
- 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);
}
| Method | Description |
|---|---|
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
Authorizationheader, the middleware does not overwrite it. - Machine names are matched exactly against the request URI’s host.
- The
defaultentry matches any host not explicitly listed. - Both
passwordandpasswdkeywords are accepted. - The
accountandmacdefkeywords 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
- HTTP/1.1: Call
.upgrade()onRequestBuilderSendorRequestBuilderLocalto set the required headers (Connection: Upgrade,Upgrade: websocket) and force HTTP/1.1. - HTTP/2: Insert a
Protocolextension into the request and useCONNECTmethod. The server must haveSETTINGS_ENABLE_CONNECT_PROTOCOLenabled. - Send the request and check for
101(H1) or200(H2 CONNECT). - Call
.upgrade()on theResponseto consume it and obtain anUpgradedSendstream. - The connection is not returned to the pool — it’s exclusively yours.
The UpgradedSend Type
UpgradedSend is a bidirectional IO stream:
- Implements
hyper::rt::Readandhyper::rt::Write(always available) - Implements
tokio::io::AsyncReadandtokio::io::AsyncWrite(when thetokiofeature is enabled) - Can be converted to the underlying
hyper::upgrade::Upgradedvia.into_inner() - Can be constructed from
hyper::upgrade::UpgradedviaUpgradedSend::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: websocketheaders → 101 - HTTP/2 extended CONNECT uses
CONNECTmethod +:protocolpseudo-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
| Method | Description |
|---|---|
.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:
ConnectionKeep-AliveProxy-AuthenticateProxy-AuthorizationProxy-ConnectionTransfer-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
ConnectionandUpgradeheaders through hop-by-hop stripping - Forces HTTP/1.1 on the upstream connection
- Sanitizes the
101response, then restores only the validatedConnection: upgradeandUpgradefields 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-Digestgeneration 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.
Parsing Link Headers
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(())
}
}
Link Fields
The Link struct contains:
| Field | Type | Description |
|---|---|---|
uri | String | The target URI |
rel | Option<String> | Relation type (e.g., next, prev, last) |
title | Option<String> | Human-readable title |
media_type | Option<String> | Expected media type of the target |
anchor | Option<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:
| Method | Parameter | Description |
|---|---|---|
by() | by | The proxy that received the request |
forwarded_for() | for | The client that made the request |
host() | host | The original Host header value |
proto() | proto | The 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
jsonfeature.
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
| Field | Type | Description |
|---|---|---|
problem_type | Option<String> | A URI identifying the problem type |
title | Option<String> | Short human-readable summary |
status | Option<u16> | The HTTP status code |
detail | Option<String> | Detailed human-readable explanation |
instance | Option<String> | URI identifying the specific occurrence |
extensions | HashMap<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
| Command | Description |
|---|---|
aioduct http | HTTP request tool with verbose TUI, redirects, retries, proxy |
aioduct download | Parallel segmented downloader with resume, WebDAV recursion |
aioduct version | Print version and exit |
Size Suffixes
Both subcommands accept human-readable size values for bandwidth and segment options:
| Suffix | Multiplier |
|---|---|
| (none) | bytes |
K / k | x 1024 |
M / m | x 1024² |
G / g | x 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
| Tab | Content |
|---|---|
| Overview | Phase timeline waterfall chart with per-phase durations |
| Trace | DNS resolution, TCP connect, TLS handshake, request/response headers, timing |
| Headers | Full request and response headers with filter |
| Body | Response body preview (binary detection with hex dump fallback, SSE streaming) |
| Events | Chronological event log of every phase transition, redirect, retry, and error |
| Summary | Transfer metrics, redirect/retry history, trailers, timings |
Controls
| Key | Action |
|---|---|
Tab / Shift+Tab | Next / previous tab |
↑ ↓ | Scroll |
c | Copy visible content to clipboard |
/ | Filter / search within current tab |
h | Toggle help overlay |
q | Quit |
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
| Flag | Long | Description |
|---|---|---|
-X | --request | HTTP method |
-d | --data | Request body (implies POST); prefix @ to read from file |
--data-binary | Binary body; prefix @ to read from file | |
-F | --form | URL-encoded form field (repeatable) |
-H | --header | Extra header (repeatable) |
-A | --user-agent | User-Agent string |
-e | --referer | Referer URL |
-u | --user | Basic auth (user:password) |
--oauth2-bearer | Bearer token | |
-L | --location | Follow redirects |
--max-redirs | Max redirect hops (default: 10) | |
-I | --head | HEAD request, show headers only |
-i | --include | Include response headers in output |
-v | --verbose | Verbose mode (TUI on terminal, plain text otherwise) |
--verbose-plain | Force plain-text verbose to stderr | |
-s | --silent | Silent mode |
-S | --show-error | Show errors in silent mode |
-o | --output | Write body to file |
-O | --remote-name | Save using filename from URL |
-D | --dump-header | Dump response headers to file |
-w | --write-out | Format string (%{http_code}, %{response_code}) |
-m | --max-time | Total request timeout (seconds) |
--connect-timeout | Connection timeout (seconds) | |
--retry | Retry count | |
--retry-max-time | Max backoff between retries (default: 60s) | |
-x | --proxy | Proxy URL (repeatable for multi-hop chaining) |
--proxy-user | Proxy auth (user:password) | |
--noproxy | Bypass proxy for listed hosts (comma-separated) | |
--system-proxy | Use proxy from env vars (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) | |
-k | --insecure | Skip TLS verification |
--http2 | Force HTTP/2 prior knowledge | |
--limit-rate | Max download speed (supports K/M/G) | |
--raw | Disable decompression | |
--compressed | Request compressed response |
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 3 | Invalid URL |
| 7 | Connection / I/O error |
| 22 | HTTP 4xx/5xx response |
| 23 | Output write error |
| 28 | Timeout |
| 60 | TLS 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
| Flag | Short | Description |
|---|---|---|
URI... | Download URIs (positional, repeatable) | |
--input-file FILE | -i | Read URIs from file (one per line) |
--dir PATH | -d | Output directory (default: .) |
--out FILENAME | -o | Output filename (single URI only) |
--split N | -s | Parallel connections per download (default: 8) |
--max-connection-per-server N | -x | Max connections per server (default: 8) |
--max-concurrent-downloads N | -j | Max concurrent downloads (default: 5) |
--min-split-size SIZE | -k | Minimum segment size (default: 1M) |
--piece-size SIZE | Override piece size (auto if unset) | |
--no-resume | Disable automatic resume | |
--file-allocation METHOD | none, prealloc (default), or falloc | |
--auto-file-renaming | Rename output if file exists | |
--allow-overwrite | Overwrite existing files | |
--checksum TYPE=DIGEST | Verify integrity (e.g. sha-256=abc...) | |
--timeout SECS | -t | Per-request timeout (default: 60s) |
--connect-timeout SECS | Connection timeout (default: 30s) | |
--max-tries N | -m | Max retry attempts (default: 5) |
--retry-wait SECS | Seconds between retries (default: 1) | |
--max-overall-download-limit SIZE | Global speed cap | |
--max-download-limit SIZE | Per-download speed cap | |
--header NAME:VALUE | -H | Extra HTTP header (repeatable) |
--referer URL | Referer header | |
--user-agent STRING | -U | User-Agent string |
--http-user USER | HTTP Basic auth username | |
--http-passwd PASS | HTTP Basic auth password | |
--all-proxy URL | Proxy for all protocols (http, https, socks4, socks4a, socks5, socks5h) | |
--check-certificate-false | Disable TLS verification | |
--recursive | -r | Recursive WebDAV download |
--max-depth N | Max recursion depth (default: unlimited) | |
--dry-run | Probe without downloading | |
--quiet | -q | Suppress all output |
--plain | Plain text progress (no TUI) | |
--human-readable | Human-readable sizes (default: true) | |
--log FILE | -l | Write debug log to file |
--log-level LEVEL | trace, 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.
| Crate | Version | Description |
|---|---|---|
| aioduct | 0.2.5 | This crate — hyper 1.x, no hyper-util, async-native |
| reqwest | 0.12 | The most popular Rust HTTP client, built on hyper + hyper-util + tower |
| hyper-util | 0.1 | hyper’s official high-level client (legacy::Client), minimal wrapper |
| isahc | 1.8 | Built 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.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 43.0 µs | — |
| hyper-util | 44.8 µs | +4.2% |
| reqwest | 48.6 µs | +13.0% |
| isahc | 91.3 µs | +112.3% |
HTTP/1.1 GET Request (text)
GET, read response as UTF-8 String.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 44.7 µs | — |
| reqwest | 47.5 µs | +6.3% |
JSON Deserialization
GET + deserialize a small JSON object ({"message":"hello","count":42}).
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 43.6 µs | — |
| reqwest | 47.4 µs | +8.7% |
POST with 4 KB Body
POST a 4 KB string, read response bytes.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 53.3 µs | — |
| reqwest | 59.8 µs | +12.2% |
| isahc | 76.2 µs | +43.0% |
Large Body Download (64 KB, HTTP/1.1)
GET a 64 KB response, read as bytes.
| Client | Mean | vs aioduct |
|---|---|---|
| hyper-util | 60.1 µs | -4.0% |
| aioduct | 62.6 µs | — |
| reqwest | 64.5 µs | +3.0% |
Large Body Download (1 MB, HTTP/1.1)
GET a 1 MB response, read as bytes.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 465.8 µs | — |
| reqwest | 481.4 µs | +3.3% |
10 Concurrent Requests (HTTP/1.1)
10 GET requests dispatched via tokio::spawn, all awaited.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 124.3 µs | — |
| reqwest | 140.9 µs | +13.4% |
50 Concurrent Requests (HTTP/1.1)
50 GET requests dispatched via tokio::spawn, all awaited.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 361.5 µs | — |
| reqwest | 425.0 µs | +17.6% |
HTTP/2 GET Request
GET via h2c (HTTP/2 over cleartext).
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 61.5 µs | — |
| hyper-util | 84.7 µs | +37.7% |
HTTP/2 Download (64 KB)
GET a 64 KB response via h2c.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 105.1 µs | — |
| hyper-util | 2,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).
| Client | Mean |
|---|---|
| aioduct | 734.0 µs |
HTTP/2 10 Concurrent Requests
10 concurrent requests multiplexed over a single h2c connection (aioduct only).
| Client | Mean |
|---|---|
| aioduct | 162.1 µs |
HTTP/2 POST with 4 KB Body
POST a 4 KB payload via h2c (aioduct only).
| Client | Mean |
|---|---|
| aioduct | 87.7 µs |
Connection Pool Overhead
Comparison of pooled vs no-pool (fresh connection per request).
| Protocol | With Pool | No Pool | Speedup |
|---|---|---|---|
| HTTP/1.1 | 44.8 µs | 95.4 µs | 2.1× |
| HTTP/2 | 80.6 µs | 191.4 µs | 2.4× |
SSE: Consume 100 Events
Parse 100 Server-Sent Events from a single response (aioduct only).
| Client | Mean |
|---|---|
| aioduct | 65.4 µs |
Multipart Upload (small)
Multipart form with two text fields.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 50.8 µs | — |
| reqwest | 66.6 µs | +31.1% |
Multipart Upload (1 MB file)
Multipart form with a 1 MB file part.
| Client | Mean | vs aioduct |
|---|---|---|
| aioduct | 846.3 µs | — |
| reqwest | 944.9 µs | +11.7% |
Streaming Upload (1 MB)
Stream a 1 MB body to an echo server.
| Client | Mean | vs aioduct |
|---|---|---|
| reqwest | 750.8 µs | -2.6% |
| aioduct | 770.9 µs | — |
Chunk Download (1 MB)
Parallel range-based download of a 1 MB file.
| Chunks | Mean |
|---|---|
| 1 chunk | 2,239 µs |
| 4 chunks | 2,308 µs |
| 8 chunks | 2,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).
| Method | Mean |
|---|---|
| bytes collect | 56.0 µs |
| frame by frame | 69.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
| Suite | Bench File | Scenarios |
|---|---|---|
e2e_h1 | benches/bench_main/e2e_h1.rs | HTTP/1.1 GET bytes/text, POST 4K, download 64K/1M |
e2e_h2 | benches/bench_main/e2e_h2.rs | HTTP/2 GET, POST 4K, download 64K/1M |
e2e_concurrent | benches/bench_main/e2e_concurrent.rs | HTTP/1.1 and HTTP/2 concurrent requests |
e2e_features | benches/bench_main/e2e_features.rs | SSE, multipart, upload 1M, chunk download, body stream, JSON |
e2e_pooling | benches/bench_main/e2e_pooling.rs | HTTP/1.1 and HTTP/2 with-pool vs no-pool |
runtime | benches/bench_main/e2e_runtime.rs | Tokio, smol, and compio runtime comparisons |
micro_pool | benches/bench_main/micro_pool.rs | Pool checkout/check-in and coalescing scans |
micro_cookie | benches/bench_main/micro_cookie.rs | Cookie request application and response storage |
micro_body | benches/bench_main/micro_body.rs | Body 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 Alias | Expands To | Runtime |
|---|---|---|
TokioClient | HttpEngineSend<TokioRuntime, tokio_rt::TcpConnector> | tokio |
SmolClient | HttpEngineSend<SmolRuntime, smol_rt::TcpConnector> | smol |
CompioClient | HttpEngineLocal<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
| Method | Description |
|---|---|
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).
| Method | Default | Description |
|---|---|---|
base_url(&str) | None | Base URL that relative request URLs resolve against (RFC 3986) |
timeout(Duration) | None | Overall request deadline |
connect_timeout(Duration) | None | One connection-acquisition deadline across pool coordination, DNS, transport, proxy, TLS, and protocol setup |
read_timeout(Duration) | None | Timeout between response body chunks |
write_timeout(Duration) | None | Timeout between request body chunks (upload) |
tcp_keepalive(Duration) | None | Enable TCP keepalive with given interval |
local_address(IpAddr) | None | Bind outgoing connections to a local IP |
address_family(AddressFamily) | Any | Restrict/prefer IP family (Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6) for resolved connections |
max_redirects(usize) | 10 | Maximum redirect hops (0 = disabled) |
referer(bool) | false | Set Referer header on redirects |
https_only(bool) | false | Reject non-HTTPS URLs |
pool_idle_timeout(Duration) | 90s | Idle connection lifetime |
pool_max_lifetime(Duration) | None | Maximum connection age before reuse stops |
pool_max_idle_per_host(usize) | 10 | Max idle connections per origin |
pool_max_active_per_host(usize) | Unlimited | Max checked-out handles and fresh connection attempts per pool key; 0 disables the cap |
pool_max_active_streams_per_connection(usize) | Unlimited | Max active HTTP/2 or HTTP/3 streams per pooled connection |
default_headers(HeaderMap) | User-Agent | Headers applied to every request |
no_default_headers() | — | Remove all default headers |
tls(RustlsConnector) | None | Custom 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) | None | Fine-grained HTTP/HTTPS proxy with bypass rules |
http2(Http2Config) | None | Configure HTTP/2 parameters (window sizes, keepalive, frame size) |
middleware(impl Middleware) | None | Add a middleware layer that can inspect/modify requests and responses |
automatic_content_digest(bool) | false | Insert SHA-256 Content-Digest for buffered native request bodies before automatic signing |
message_signature(config, signer) | None | Sync automatic RFC 9421 request signing for finalized native requests |
message_signature_async(config, signer) | None | Send-runtime async automatic RFC 9421 request signing |
message_signature_async_local(config, signer) | None | Local-runtime async automatic RFC 9421 request signing |
retry(RetryConfig) | None | Default retry policy for all requests |
cookie_jar(CookieJar) | None | Enable automatic cookie management |
rate_limiter(RateLimiter) | None | Token-bucket rate limiter for outgoing requests |
cache(HttpCache) | None | Enable in-memory HTTP response caching |
base_url(&str)returnsResultbecause it validates the URL eagerly; the other setters returnSelf. 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
| Timeout | Phase covered | Phase not covered |
|---|---|---|
timeout(Duration) | One request attempt until send() returns, including redirects, response headers, and body upload | Retry 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 miss | Pooled request dispatch, request upload, response headers after dispatch, and response body reads |
read_timeout(Duration) | Gaps between response body chunks | Waiting for response headers and request upload |
write_timeout(Duration) | Gaps while uploading request body chunks | Waiting 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:
| Error | Typical source |
|---|---|
Timeout | Overall request deadline from timeout() |
ConnectTimeout | Connection establishment deadline from connect_timeout() |
ReadTimeout | Gap between response body chunks from read_timeout() |
WriteTimeout | Gap 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.
| Trait | Description |
|---|---|
HttpClient | Common client interface (get, post, etc.) |
RequestBuilderExt | Common request builder methods (header, body, etc.) |
ResponseExt | Common response methods (status, text, bytes, etc.) |
ByteStreamExt | Streaming 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):
| Status | Behavior |
|---|---|
| 301 | Follow with GET, drop body + content headers |
| 302 | Follow with GET, drop body + content headers |
| 303 | Follow with GET, drop body + content headers |
| 307 | Follow with original method + body |
| 308 | Follow 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
| Phase | Key Fields | Fires When |
|---|---|---|
Started | — | Request execution begins |
PoolCheckoutComplete | outcome, blocked_duration | Pool lookup finishes |
DnsResolved | addrs, duration | DNS resolution completes |
TcpConnected | remote_addr, duration, protocol | TCP connection established |
TlsHandshakeComplete | duration, alpn_protocol, peer_certificate_der | TLS negotiation done |
RequestSent | duration, headers | Finalized request is about to be handed to the protocol sender |
ResponseStarted | waiting_duration | TTFB — first response byte received |
ResponseComplete | status, protocol, total_duration | Response headers complete |
Redirected | status, from, to | A redirect was followed |
Retrying | reason, attempt, max_retries, backoff | A retry is about to be attempted |
Failed | error, retry, elapsed | Request failed with an error |
BytesTransferred | direction, chunk_bytes, cumulative_bytes | Per-chunk (body streaming) |
TransferComplete | direction, total_bytes, throughput_bytes_per_sec | Transfer in one direction finished |
TransferAborted | direction, bytes_transferred, error | Transfer aborted mid-stream |
TrailersReceived | headers | HTTP 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
| Method | Description |
|---|---|
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) |