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 — h3 + h3-quinn behind a feature flag.
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 | Built-in |
Getting Started
Installation
Add aioduct to your Cargo.toml with at least one runtime feature:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio"] }
For HTTPS support, add the rustls backend and exactly one rustls crypto provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring"] }
To use rustls with AWS-LC instead, select the AWS-LC provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }
For JSON serialization/deserialization:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json"] }
Quick Example
use aioduct::{TokioClient, StatusCode};
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client
.get("http://httpbin.org/get")?
.send()
.await?;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.text().await?;
println!("{body}");
Ok(())
}
HTTPS with rustls
use aioduct::TokioClient;
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::with_rustls();
let resp = client
.get("https://httpbin.org/get")?
.send()
.await?;
println!("status: {}", resp.status());
Ok(())
}
Sending JSON
Requires the json feature.
use aioduct::TokioClient;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Deserialize)]
struct User {
id: u64,
name: String,
}
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::with_rustls();
let resp = client
.post("https://api.example.com/users")?
.json(&CreateUser {
name: "Alice".into(),
email: "alice@example.com".into(),
})?
.send()
.await?;
let user: User = resp.json().await?;
println!("created user {} with id {}", user.name, user.id);
Ok(())
}
Using the smol Runtime
use aioduct::SmolClient;
fn main() -> Result<(), aioduct::Error> {
smol::block_on(async {
let client = SmolClient::new();
let resp = client
.get("http://httpbin.org/get")?
.send()
.await?;
println!("status: {}", resp.status());
Ok(())
})
}
Client Configuration
#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::TokioClient;
let client = TokioClient::builder()
.timeout(Duration::from_secs(30))
.max_redirects(5)
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(10)
.build()?;
}
Feature Flags
aioduct uses feature flags to control runtime, TLS, and serialization dependencies. The default feature set is empty — you must enable at least one runtime.
Available Features
| 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 | Browser/WASM runtime |
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 | tokio | Stable | Synchronous blocking client wrapper |
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 |
http3 | h3, h3-quinn, quinn | Experimental | HTTP/3 transport; currently requires rustls plus one rustls provider |
TLS Provider Features
Use rustls for the HTTPS backend and choose exactly one rustls crypto provider: rustls-ring or rustls-aws-lc-rs. The backend and provider flags are separate so future TLS backends, such as a reserved native-tls/OpenSSL backend, can compose with higher-level HTTP features without changing the rustls provider model. rustls-native-roots is provider-neutral: it enables the rustls backend and composes with either provider.
Compile Error Without Runtime
If no runtime feature is selected, aioduct emits a compile error:
error: aioduct: enable at least one runtime feature: tokio, smol, compio, or wasm
Common Feature Combinations
# HTTP only, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio"] }
# HTTPS + JSON, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json"] }
# HTTPS with AWS-LC, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }
# HTTPS with AWS-LC and OS native roots
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls-native-roots", "rustls-aws-lc-rs"] }
# HTTP only, smol runtime
aioduct = { version = "0.2.0-alpha.1", features = ["smol"] }
# HTTPS, smol runtime
aioduct = { version = "0.2.0-alpha.1", features = ["smol", "rustls", "rustls-ring"] }
# HTTP only, compio runtime (experimental)
aioduct = { version = "0.2.0-alpha.1", features = ["compio"] }
# HTTPS + JSON + compression, tokio runtime
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "json", "gzip", "brotli", "zstd", "deflate"] }
# Blocking client (synchronous)
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "blocking"] }
# With tracing and OpenTelemetry
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "tracing", "otel"] }
# With tower integration
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "tower"] }
# Hickory DNS resolver
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "hickory-dns"] }
# DNS-over-HTTPS
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "doh"] }
# DNS-over-TLS
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring", "dot"] }
# HTTP/3 with ring
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-ring"] }
# HTTP/3 with AWS-LC
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-aws-lc-rs"] }
Core Dependencies (Always Included)
These are pulled in regardless of feature flags:
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 # Re-exports, compile_error gate, type aliases
error.rs # Error enum, type aliases
engine.rs # HttpEngineCore<B>, HttpEngineSend<R,C>, HttpEngineLocal<R,C>
engine_builder.rs # HttpEngineBuilder<R,C> — fluent client configuration
request.rs # RequestBuilderSend<R,C>, RequestBuilderLocal<R,C>
response.rs # ResponseBodySend, ResponseBodyLocal — status, headers, body consumption
body.rs # BodyStream, RequestBody (buffered/streaming)
timeout.rs # Pin-projected Timeout future
cookie.rs # CookieJar, Cookie, Set-Cookie parsing
cache.rs # HttpCache, CacheConfig — in-memory HTTP cache
retry.rs # RetryConfig, RetryBudget — exponential backoff
throttle.rs # RateLimiter — token-bucket rate limiting
bandwidth.rs # BandwidthLimiter — byte-rate download throttle
redirect.rs # RedirectPolicy, RedirectAction
middleware.rs # Middleware trait
digest_auth.rs # DigestAuth — HTTP Digest challenge-response
netrc.rs # Netrc, NetrcMiddleware — .netrc credential injection
happy_eyeballs.rs # RFC 6555 IPv6/IPv4 connection racing
sse.rs # SseStream, SseEvent — Server-Sent Events
multipart.rs # Multipart, Part — multipart/form-data
chunk_download.rs # ChunkDownload — parallel range requests
upgrade.rs # Upgraded — HTTP/1.1 protocol upgrade
decompress.rs # DecompressBody — gzip/brotli/zstd/deflate
proxy.rs # ProxyConfig, ProxySettings, NoProxy
socks4.rs # SOCKS4/4a handshake
socks5.rs # SOCKS5 handshake
blocking.rs # Blocking client wrapper (requires tokio)
traits.rs # HttpClient, RequestBuilderExt, ResponseExt, ByteStreamExt
runtime/
mod.rs # RuntimeCompletion, RuntimePoll, RuntimeLocal traits
tokio_rt.rs # TokioRuntime, TcpConnector, TokioIo
smol_rt.rs # SmolRuntime, TcpConnector, SmolIo
compio_rt.rs # CompioRuntime, TcpConnector
connector.rs # ConnectorSend, ConnectorLocal traits, SocketConfig
pool/
mod.rs # ConnectionPool — keyed pooling
connection.rs # PooledConnection, HttpConnection enum
tls/
mod.rs # TlsConnect trait, re-exports
rustls_connector.rs # RustlsConnector, TlsStream, ALPN
h3/
mod.rs # HTTP/3 transport (experimental)
http2.rs # Http2Config
hickory.rs # HickoryResolver (requires hickory-dns)
wasm/ # WASM/browser runtime
Request Flow
A request in aioduct goes through these stages:
client.get("http://example.com/path")?
-> RequestBuilderSend (accumulate headers, body, timeout, query params)
-> RequestBuilderSend::send()
-> apply timeout wrapper (Timeout future)
-> rate limiter wait (if configured)
-> check HTTP cache (if configured, return cached response on hit)
-> HttpEngineCore::execute()
-> merge default headers
-> apply cookie jar cookies (if configured)
-> retry loop (if configured):
-> redirect loop (up to max_redirects):
-> run middleware on_request hooks
-> build http::Request with method, path-only URI, headers
-> execute_single()
-> pool checkout (reuse existing connection?)
-> if miss: ConnectorSend::connect(&SocketConfig)
-> TCP connect -> TLS handshake (if HTTPS)
-> ALPN -> select h1 or h2 sender
-> send request on connection
-> pool checkin
-> digest auth retry (if 401 + WWW-Authenticate: Digest)
-> run middleware on_response hooks
-> store response cookies in jar (if configured)
-> check redirect status -> follow or return
-> cache response (if configured and cacheable)
-> decompress body (if content-encoding matches)
-> apply bandwidth limiter (if configured)
-> ResponseBodySend
Key Design Decisions
No hyper-util
hyper 1.x provides raw connection-level primitives. hyper-util wraps them in a legacy Client that mimics hyper 0.x behavior. aioduct skips hyper-util entirely and implements:
- IO adapters (TokioIo, SmolIo): Bridge runtime-specific
AsyncRead/AsyncWritetohyper::rt::Read/hyper::rt::Write. Each is ~50 lines of unsafe pin projection. - HyperExecutor: A generic executor that delegates
spawnto the active Runtime. UsesPhantomData<fn() -> R>(notPhantomData<R>) to ensure it is alwaysUnpin, which hyper’s h2 handshake requires.
Split Engine Types: Send vs Local
The v0.2 architecture splits the client into two engine types to cleanly support both poll-based and completion-based runtimes:
HttpEngineSend<R: RuntimePoll, C: ConnectorSend>— for runtimes where futures 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 is keyed by (scheme, authority) and stores connections in a VecDeque per key. On checkout, expired connections are evicted. On checkin, the pool respects max_idle_per_host. HTTP/2 connections can be shared across concurrent requests since h2 multiplexes streams.
TLS State Machine
The rustls integration implements an async TLS handshake as a manual state machine. Because rustls::ClientConnection expects synchronous std::io::Read/Write, the adapter uses helper functions that wrap async streams and return WouldBlock when the underlying stream would block. This avoids spawning a blocking task or using a separate thread for the handshake.
Timeout via Pin Projection
The Timeout type is a pin-projected enum with two variants:
NoTimeout { future }— passes through 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 is split into a three-level trait hierarchy, from most general to most capable:
#![allow(unused)]
fn main() {
pub trait RuntimeCompletion: 'static {
fn block_on<F: Future>(future: F) -> F::Output;
}
pub trait RuntimePoll: RuntimeCompletion {
fn spawn_send<F>(future: F)
where
F: Future<Output = ()> + Send + 'static;
fn sleep(duration: Duration) -> impl Future<Output = ()> + Send;
}
pub trait RuntimeLocal: RuntimeCompletion {
fn spawn_local<F>(future: F)
where
F: Future<Output = ()> + 'static;
fn sleep(duration: Duration) -> impl Future<Output = ()>;
}
}
RuntimeCompletion (Base)
The foundation trait. Provides block_on to drive a future to completion synchronously. Every runtime implements this.
RuntimePoll (Send-capable runtimes)
Extends RuntimeCompletion with:
spawn_send: Spawn aSendfuture as a detached background task. Used for driving hyper connection futures on work-stealing schedulers.sleep: Create aSendsleep future for the given duration. Used for timeouts and pool idle eviction.
Implemented by TokioRuntime and SmolRuntime.
RuntimeLocal (Thread-local runtimes)
Extends RuntimeCompletion with:
spawn_local: Spawn a!Sendfuture on the current thread. Used for thread-per-core runtimes where tasks never cross thread boundaries.sleep: Create a sleep future (not required to beSend).
Implemented by CompioRuntime.
Connector Traits
Networking is decoupled from the runtime via connector traits. Each connector is responsible for establishing a TCP connection given a SocketConfig.
#![allow(unused)]
fn main() {
pub trait ConnectorSend: Clone + Send + Sync + 'static {
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
fn connect(&self, config: &SocketConfig) -> impl Future<Output = io::Result<Self::Stream>> + Send;
}
pub trait ConnectorLocal: 'static {
type Stream: AsyncRead + AsyncWrite + Unpin + 'static;
fn connect(&self, config: &SocketConfig) -> impl Future<Output = io::Result<Self::Stream>>;
}
}
ConnectorSend
For use with HttpEngineSend<R, C>. Must be Clone + Send + Sync so it can be shared across tasks on a work-stealing scheduler. The returned stream must be Send.
ConnectorLocal
For use with HttpEngineLocal<R, C>. No Send bounds — the connector and its streams live on a single thread.
SocketConfig
Both connector traits receive a SocketConfig that contains the target address, port, DNS resolution hints, TCP options (nodelay, keepalive), and local bind address.
Built-in Implementations
TokioRuntime + TcpConnector
Enabled with features = ["tokio"].
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
}
TokioRuntimeimplementsRuntimePollusingtokio::runtime::Handle::block_on,tokio::spawn, andtokio::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();
}
SmolRuntimeimplementsRuntimePollusingsmol::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.
CompioRuntimeimplementsRuntimeLocalusingcompio_runtime::block_on,compio_runtime::spawn, and compio’s native timers.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.
HyperExecutor
hyper’s HTTP/2 handshake requires an Executor to spawn background tasks for connection management. aioduct provides a generic HyperExecutor<R> that delegates to R::spawn_send (for RuntimePoll) or R::spawn_local (for RuntimeLocal):
#![allow(unused)]
fn main() {
pub struct HyperExecutor<R>(PhantomData<fn() -> R>);
}
The PhantomData<fn() -> R> (rather than PhantomData<R>) ensures HyperExecutor is always Unpin regardless of R, which hyper’s h2 handshake requires.
Implementing a Custom Runtime
To add a new poll-based runtime:
- 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.
TLS & HTTPS
aioduct supports HTTPS via rustls. No TLS library is included by default — plain HTTP works without any TLS dependency.
Enabling HTTPS
Use the rustls TLS backend with the ring crypto provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-ring"] }
Use the same rustls backend with the AWS-LC crypto provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls", "rustls-aws-lc-rs"] }
Add rustls-native-roots alongside either provider to use the OS certificate store:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "rustls-native-roots", "rustls-aws-lc-rs"] }
Quick Start
use aioduct::TokioClient;
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
// with_rustls() configures WebPKI root certificates automatically
let client = TokioClient::with_rustls();
let resp = client
.get("https://httpbin.org/get")?
.send()
.await?;
println!("status: {}", resp.status());
Ok(())
}
How It Works
Handshake
The TLS handshake is fully async, implemented as a manual state machine:
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.
Custom TLS Configuration
For advanced use cases, configure the RustlsConnector directly:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;
let client = TokioClient::builder()
.tls(RustlsConnector::with_webpki_roots())
.build()?;
}
Accepting Invalid Certificates
For development and testing, you can disable certificate verification:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.danger_accept_invalid_certs()
.build()?;
}
Warning: Never use this in production. It disables all certificate verification, making the connection vulnerable to MITM attacks.
HTTPS-Only Mode
To enforce that all requests use HTTPS:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;
let client = TokioClient::builder()
.tls(RustlsConnector::with_webpki_roots())
.https_only(true)
.build()?;
// This will return an error:
// client.get("http://example.com")?.send().await?;
}
Error Handling
TLS errors surface as Error::Tls(Box<dyn std::error::Error + Send + Sync>). Common failure modes:
- Certificate verification failure (expired, wrong hostname, untrusted CA)
- No TLS connector configured (HTTPS URL without the
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) — for example, (https, api.example.com:443). Two requests to the same origin share pooled connections; requests to different origins use separate pools.
Lifecycle
- Checkout: When a request is made, the pool checks for an existing idle connection to the target origin. It uses LIFO ordering (most recently returned first) to prefer the freshest connections. Each candidate is checked for readiness — if a connection is stale or closed, it’s discarded and the next one is tried.
- Send: The request is sent on the connection (either reused or freshly established).
- Checkin: After the response headers are received, the connection is returned to the pool. When at capacity, the oldest idle connection is evicted to make room for the new one.
Idle Eviction
Connections are evicted in three ways:
- On checkout: Expired connections (past idle timeout) are discarded while searching for a ready one.
- On checkin: When the per-host queue is full, the oldest connection is evicted.
- Background reaper: A periodic background task runs at the idle timeout interval and removes all expired connections, preventing memory leaks from unused hosts.
HTTP/2 Multiplexing
HTTP/2 connections support multiplexing — multiple concurrent requests share a single connection. The pool tracks the hyper SendRequest handle, which naturally supports this. When an h2 connection is checked out, it remains usable by other requests concurrently.
HTTP/3 (QUIC) Pooling
When the http3 feature is enabled with the rustls backend and one rustls provider, QUIC connections are pooled alongside TCP connections. Like HTTP/2, HTTP/3 multiplexes streams over a single connection, so a pooled QUIC connection can serve multiple sequential requests to the same origin without re-establishing the handshake. The pool uses the same (scheme, authority) key for both TCP and QUIC connections.
Configuration
#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::TokioClient;
let client = TokioClient::builder()
.pool_idle_timeout(Duration::from_secs(90)) // default: 90s
.pool_max_idle_per_host(10) // default: 10
.build()?;
}
Options
| Option | Default | Description |
|---|---|---|
pool_idle_timeout | 90s | How long an idle connection is kept before eviction |
pool_max_idle_per_host | 10 | Maximum idle connections per (scheme, authority) |
Connection Health
On checkout, the pool verifies each candidate connection is still ready using hyper’s SendRequest::is_ready(). If a connection has been closed by the server (e.g., due to keep-alive timeout), it’s discarded and the next pooled connection is tried. If no ready connection is found, a new one is established.
Connection Coalescing
When enabled (default), aioduct reuses h2/h3 connections for different hostnames that share the same TLS certificate, matching browser behavior per RFC 7540 §9.1.1.
How It Works
- 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.
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.
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 5xx server errors |
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 — request-level or client-level timeout exceeded
- 5xx server errors — 500, 502, 503, etc. (when
retry_on_statusis true) - 429 Too Many Requests — rate limiting responses (when
retry_on_statusis true)
Client errors (4xx, other than 429) are never retried. To disable status-based retry, set retry_on_status(false).
Retry-After Header
When a server responds with a Retry-After header (common on 429 and 503 responses), aioduct uses the server’s requested delay instead of its own exponential backoff for that attempt. Both formats are supported:
- Seconds:
Retry-After: 120— wait 120 seconds - HTTP-date:
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT— wait until the specified time
If the Retry-After value is missing or unparseable, the normal backoff delay is used.
Retry Budget
A RetryBudget prevents retry storms by limiting the total retry rate across all requests. Each successful (non-retried) request deposits tokens; each retry attempt withdraws one. When the budget is exhausted, retries are suppressed.
#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig, RetryBudget};
let client = TokioClient::builder()
.retry(
RetryConfig::default()
.budget(RetryBudget::new(10, 1)), // max 10 tokens, +1 per success
)
.build()?;
}
Client-Level Retry
Set a default retry policy for all requests:
#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};
let client = TokioClient::builder()
.retry(
RetryConfig::default()
.max_retries(5)
.initial_backoff(Duration::from_millis(200))
.max_backoff(Duration::from_secs(10)),
)
.build()?;
}
Per-Request Override
A retry config on a request takes precedence over the client default:
#![allow(unused)]
fn main() {
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};
let client = TokioClient::new();
let resp = client
.post("http://example.com/idempotent-endpoint")?
.retry(RetryConfig::default().max_retries(1))
.body("payload")
.send()
.await?;
Ok::<_, aioduct::Error>(())
}
Example: Resilient LLM API Client
use std::time::Duration;
use aioduct::{TokioClient, RetryConfig};
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
.retry(
RetryConfig::default()
.max_retries(3)
.initial_backoff(Duration::from_millis(500))
.backoff_multiplier(2.0),
)
.timeout(Duration::from_secs(30))
.build()?;
let resp = client
.post("https://api.example.com/v1/chat/completions")?
.bearer_auth("sk-...")
.header_str("content-type", "application/json")?
.body(r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#)
.send()
.await?;
println!("{}", resp.text().await?);
Ok(())
}
Redirect Policy
aioduct follows HTTP redirects automatically by default (up to 10 hops). You can customize this behavior with RedirectPolicy.
Policies
| 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.
No Redirects
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectPolicy};
let client = TokioClient::builder()
.redirect_policy(RedirectPolicy::none())
.build()?;
}
Limited Redirects
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectPolicy};
// Also available via the shorthand:
let client = TokioClient::builder()
.max_redirects(5)
.build()?;
// Equivalent to:
let client = TokioClient::builder()
.redirect_policy(RedirectPolicy::limited(5))
.build()?;
}
Custom Policy
The custom callback receives the current URI, next (redirect target) URI, status code, and HTTP method. Return RedirectAction::Follow to follow the redirect, or RedirectAction::Stop to stop and return the redirect response.
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, RedirectAction, RedirectPolicy};
let client = TokioClient::builder()
.redirect_policy(RedirectPolicy::custom(|current, next, status, method| {
// Only follow redirects that stay on the same host
if current.host() == next.host() {
RedirectAction::Follow
} else {
RedirectAction::Stop
}
}))
.build()?;
}
Use Cases for Custom Policies
- Same-origin only: prevent redirects to external domains
- HTTPS-only: reject downgrades from HTTPS to HTTP
- Logging: log each redirect decision while still following
- Domain allowlist: only follow redirects to trusted domains
Referer Header
By default, aioduct does not set a Referer header on redirect hops. Enable it on the client builder:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.referer(true)
.build()?;
}
When enabled, each redirect sets the Referer header to the URI of the previous request.
Multipart/Form-Data
aioduct supports building multipart/form-data request bodies for file uploads and mixed form submissions.
Basic Usage
use aioduct::{TokioClient, Multipart};
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let form = Multipart::new()
.text("username", "alice")
.text("description", "Profile photo");
let resp = client
.post("http://example.com/upload")?
.multipart(form)
.send()
.await?;
println!("status: {}", resp.status());
Ok(())
}
Text Fields
Add plain text form fields with .text(name, value):
#![allow(unused)]
fn main() {
use aioduct::Multipart;
let form = Multipart::new()
.text("field1", "value1")
.text("field2", "value2");
}
File Parts
Add file parts with .file(name, filename, content_type, data):
#![allow(unused)]
fn main() {
use aioduct::Multipart;
let form = Multipart::new()
.text("description", "My document")
.file("document", "report.pdf", "application/pdf", include_bytes!("../../Cargo.toml").as_slice());
}
The data parameter accepts anything that implements Into<Bytes> — &[u8], Vec<u8>, String, Bytes, etc.
Mixed Forms
Combine text fields and file parts freely:
use aioduct::{TokioClient, Multipart};
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let image_data = std::fs::read("photo.jpg").unwrap();
let form = Multipart::new()
.text("title", "Vacation photo")
.text("album", "Summer 2025")
.file("photo", "photo.jpg", "image/jpeg", image_data);
let resp = client
.post("http://example.com/api/photos")?
.multipart(form)
.send()
.await?;
println!("uploaded: {}", resp.status());
Ok(())
}
Wire Format
The generated body follows RFC 2046 multipart encoding:
------aioduct<boundary>\r\n
Content-Disposition: form-data; name="field1"\r\n
\r\n
value1\r\n
------aioduct<boundary>\r\n
Content-Disposition: form-data; name="file"; filename="photo.jpg"\r\n
Content-Type: image/jpeg\r\n
\r\n
<binary data>\r\n
------aioduct<boundary>--\r\n
The boundary is auto-generated per Multipart instance. The Content-Type header is set automatically when using .multipart() on the request builder.
Streaming Downloads
aioduct supports streaming response bodies chunk-by-chunk, avoiding the need to buffer the entire response in memory. This is essential for downloading large files.
BodyStream
Convert a response into a BodyStream that yields Bytes chunks:
use aioduct::TokioClient;
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client
.get("http://example.com/large-file.bin")?
.send()
.await?;
let mut stream = resp.into_bytes_stream();
let mut total = 0usize;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
total += chunk.len();
// process chunk...
}
println!("downloaded {total} bytes");
Ok(())
}
Streaming to a File
Combine BodyStream with tokio::fs::File to download directly to disk:
use aioduct::TokioClient;
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = TokioClient::new();
let resp = client
.get("http://example.com/large-file.bin")?
.send()
.await?;
let mut file = tokio::fs::File::create("output.bin").await?;
let mut stream = resp.into_bytes_stream();
while let Some(chunk) = stream.next().await {
file.write_all(&chunk?).await?;
}
file.flush().await?;
Ok(())
}
Choosing Between Methods
| 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.
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 h3, h3-quinn, and quinn.
Feature Flag
Enable the http3 transport feature with the rustls backend and a rustls crypto provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-ring"] }
To use AWS-LC instead of ring, select the AWS-LC rustls provider:
[dependencies]
aioduct = { version = "0.2.0-alpha.1", features = ["tokio", "http3", "rustls", "rustls-aws-lc-rs"] }
The http3 feature only selects the QUIC/HTTP/3 transport dependencies. Today HTTP/3 still requires the rustls backend because quinn uses rustls for QUIC TLS; choose exactly one of rustls-ring or rustls-aws-lc-rs.
Usage
There are two modes for HTTP/3:
Always-H3 Mode
Force all HTTPS requests through QUIC/HTTP/3:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
// All HTTPS requests will use HTTP/3
let client = TokioClient::with_http3()?;
}
Or via the builder:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;
let client = TokioClient::builder()
.tls(RustlsConnector::with_webpki_roots())
.http3(true)?
.build()?;
}
Alt-Svc Auto-Upgrade Mode
Start with HTTP/1.1 or HTTP/2 over TCP, and automatically upgrade to HTTP/3 when the server advertises it via the Alt-Svc header:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
// First request uses TCP; upgrades to QUIC when Alt-Svc is seen
let client = TokioClient::with_alt_svc_h3()?;
}
Or via the builder:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use aioduct::tls::RustlsConnector;
let client = TokioClient::builder()
.tls(RustlsConnector::with_webpki_roots())
.alt_svc_h3(true)
.build()?;
}
Important:
.tls()must be called before.http3(true)or.alt_svc_h3(true)when you provide a custom TLS connector because HTTP/3 reuses that rustls configuration to build the QUIC endpoint.
Alt-Svc Protocol Upgrade
When Alt-Svc auto-upgrade is enabled (.alt_svc_h3(true) or with_alt_svc_h3()):
- 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 — the pool checks for an existing idle connection to the same
(scheme, authority)before establishing a new one. Like HTTP/2, HTTP/3 multiplexes streams over a single connection.
When HTTP/3 is not enabled (default), the client uses TCP with HTTP/1.1 or HTTP/2 negotiated via ALPN, even for HTTPS.
0-RTT (Early Data)
For repeat connections to servers that support session tickets, aioduct can send the first request immediately without waiting for a full QUIC handshake:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.tls(aioduct::tls::RustlsConnector::with_webpki_roots())
.http3(true)?
.h3_zero_rtt(true)
.build()?;
}
Safety
0-RTT is only used for idempotent methods (GET, HEAD, OPTIONS) because early data can be replayed by an attacker. POST, PUT, DELETE, and PATCH always wait for the full handshake.
If the server rejects 0-RTT, the client transparently falls back to a full handshake with no user intervention required.
When to Enable
- Enable for latency-sensitive read-heavy workloads (API polling, CDN fetches) where the server is known to support session tickets.
- Leave disabled (default) if your application has strict non-replay requirements or the server doesn’t benefit from it.
Limitations
- Experimental — the h3 ecosystem (h3 0.0.8, h3-quinn 0.0.10) is pre-1.0.
- No fallback — in always-h3 mode, if the server doesn’t support QUIC, the request fails rather than falling back to TCP. Use Alt-Svc mode or the default (non-h3) client for servers where QUIC support is uncertain.
- Tokio only — quinn requires tokio, so HTTP/3 is only available with the
tokioruntime feature. - rustls required today — future TLS backend work may change the available combinations, but current HTTP/3 support composes with rustls provider features.
Cookie Jar
aioduct supports automatic cookie management through a CookieJar. When enabled, cookies from Set-Cookie response headers are stored and automatically sent in subsequent requests to the same domain.
Enabling Cookies
Create a CookieJar and pass it to the client builder:
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, CookieJar};
let jar = CookieJar::new();
let client = TokioClient::builder()
.cookie_jar(jar)
.build()?;
}
How It Works
- 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.
Shared State
HstsStore uses Arc<Mutex<...>> internally, so cloning a store shares state between clients:
#![allow(unused)]
fn main() {
use aioduct::HstsStore;
let store = HstsStore::new();
let store2 = store.clone(); // shares the same data
}
Clearing the Store
#![allow(unused)]
fn main() {
use aioduct::HstsStore;
let store = HstsStore::new();
// ... use with client ...
store.clear();
}
Response Decompression
aioduct can automatically decompress response bodies based on the Content-Encoding header. Each compression algorithm is gated behind its own feature flag.
Feature Flags
| Feature | Codec | Crate |
|---|---|---|
gzip | gzip | flate2 |
deflate | deflate | flate2 |
brotli | br | brotli |
zstd | zstd | zstd |
[dependencies]
aioduct = { version = "0.2.0-alpha.1", 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 and SOCKS5 proxies. For HTTP targets via an HTTP proxy, the request is sent directly to the proxy. For HTTPS targets, a CONNECT tunnel is established. SOCKS5 proxies tunnel all traffic regardless of scheme.
Basic Usage
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};
// HTTP proxy
let client = TokioClient::builder()
.proxy(ProxyConfig::http("http://proxy.example.com:8080").unwrap())
.build()?;
// SOCKS5 proxy
let client = TokioClient::builder()
.proxy(ProxyConfig::socks5("socks5://socks-proxy.example.com:1080").unwrap())
.build()?;
}
System Proxy (Environment Variables)
Use system_proxy() to read proxy settings from environment variables:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.system_proxy()
.build()?;
}
This reads:
HTTP_PROXY/http_proxy— proxy for HTTP 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.
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 |
Advanced: Separate HTTP/HTTPS Proxies
Use ProxySettings for fine-grained control:
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig, ProxySettings, NoProxy};
let settings = ProxySettings::all(
ProxyConfig::http("http://proxy.example.com:8080").unwrap()
)
.no_proxy(NoProxy::new("localhost, .internal.corp, 10.0.0.0/8"));
let client = TokioClient::builder()
.proxy_settings(settings)
.build()?;
}
You can also set different proxies for HTTP and HTTPS:
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig, ProxySettings, NoProxy};
let settings = ProxySettings::default()
.http(ProxyConfig::http("http://http-proxy:3128").unwrap())
.https(ProxyConfig::http("http://https-proxy:3129").unwrap())
.no_proxy(NoProxy::new("localhost"));
let client = TokioClient::builder()
.proxy_settings(settings)
.build()?;
}
Proxy Authentication
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};
let client = TokioClient::builder()
.proxy(
ProxyConfig::http("http://proxy.example.com:8080")
.unwrap()
.basic_auth("user", "pass"),
)
.build()?;
}
How It Works
HTTP Targets
For plain HTTP requests, the client connects to the proxy and sends the request with the full absolute URI in the request line. The proxy forwards the request to the target server.
HTTPS Targets (CONNECT Tunnel)
For HTTPS requests, the client:
- Connects to the proxy via TCP
- Sends
CONNECT host:port HTTP/1.1to establish a tunnel - Waits for a
200response 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.
Example: Corporate Proxy
use aioduct::{TokioClient, ProxyConfig};
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
.proxy(
ProxyConfig::http("http://corporate-proxy:3128")
.unwrap()
.basic_auth("employee", "password"),
)
.tls(aioduct::tls::RustlsConnector::with_webpki_roots())
.build()?;
let resp = client
.get("https://api.example.com/data")?
.send()
.await?;
println!("{}", resp.text().await?);
Ok(())
}
SOCKS4/SOCKS4a Proxy
SOCKS4/SOCKS4a proxies are also supported. SOCKS4a extends SOCKS4 with domain name resolution on the proxy side:
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};
// SOCKS4a proxy (domain resolution on proxy)
let client = TokioClient::builder()
.proxy(ProxyConfig::socks4("socks4a://localhost:1080").unwrap())
.build()?;
// SOCKS4 proxy
let client = TokioClient::builder()
.proxy(ProxyConfig::socks4("socks4://localhost:1080").unwrap())
.build()?;
}
SOCKS4 supports optional user ID authentication (passed via basic_auth — only the username is used).
Environment variables with socks4:// or socks4a:// URLs are automatically detected by system_proxy().
SOCKS5 Proxy
SOCKS5 proxies tunnel TCP connections at a lower level than HTTP proxies. After the SOCKS5 handshake, the TCP stream is used directly — for HTTP targets, the client sends a normal request; for HTTPS targets, TLS is negotiated over the tunnel.
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, ProxyConfig};
// Without auth
let client = TokioClient::builder()
.proxy(ProxyConfig::socks5("socks5://localhost:1080").unwrap())
.build()?;
// With username/password auth
let client = TokioClient::builder()
.proxy(
ProxyConfig::socks5("socks5://localhost:1080")
.unwrap()
.basic_auth("user", "pass"),
)
.build()?;
}
Environment variables with socks5:// URLs are automatically detected by system_proxy().
Limitations
- SOCKS5 supports no-auth and username/password authentication (RFC 1928/1929)
- SOCKS4 supports optional user ID authentication
- The HTTP proxy URI must use
http://scheme; the SOCKS5 proxy URI must usesocks5://; the SOCKS4 proxy URI must usesocks4://orsocks4a://
HTTP/2 Tuning
aioduct automatically negotiates HTTP/2 when the server supports it via ALPN during TLS. You can fine-tune HTTP/2 connection parameters using Http2Config.
Usage
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Http2Config};
use std::time::Duration;
let client = TokioClient::builder()
.tls(aioduct::tls::RustlsConnector::with_webpki_roots())
.http2(
Http2Config::new()
.initial_stream_window_size(2 * 1024 * 1024)
.initial_connection_window_size(4 * 1024 * 1024)
.max_frame_size(32_768)
.adaptive_window(true)
.keep_alive_interval(Duration::from_secs(20))
.keep_alive_timeout(Duration::from_secs(10))
.keep_alive_while_idle(true),
)
.build()?;
}
Available Options
| 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 tracked |
Flow Control Window Sizing
HTTP/2 uses flow control to prevent a fast sender from overwhelming a slow receiver. The default window sizes (65,535 bytes) are conservative. For high-bandwidth or high-latency connections, larger windows improve throughput:
#![allow(unused)]
fn main() {
use aioduct::Http2Config;
let config = Http2Config::new()
.initial_stream_window_size(1024 * 1024) // 1 MB per stream
.initial_connection_window_size(2 * 1024 * 1024) // 2 MB total
.adaptive_window(true); // auto-tune
}
Keep-Alive
HTTP/2 PING frames detect dead connections before the OS does. This is especially useful for long-lived connections behind load balancers:
#![allow(unused)]
fn main() {
use aioduct::Http2Config;
use std::time::Duration;
let config = Http2Config::new()
.keep_alive_interval(Duration::from_secs(30))
.keep_alive_timeout(Duration::from_secs(10))
.keep_alive_while_idle(true);
}
When to Use
- Default (no config): Fine for most use cases
- Large downloads/uploads: Increase window sizes
- High-latency links: Enable adaptive window
- Long-lived connections: Enable keep-alive PINGs
- Behind aggressive LBs/proxies: Short keep-alive intervals
Middleware
aioduct supports a middleware layer that lets you intercept and modify requests before they are sent and responses after they are received. This is useful for cross-cutting concerns like logging, metrics, authentication token refresh, or header injection.
The Middleware Trait
#![allow(unused)]
fn main() {
pub trait Middleware: Send + Sync + 'static {
fn on_request(&self, request: &mut http::Request<RequestBodySend>, uri: &Uri) { }
fn on_response(&self, response: &mut http::Response<RequestBodySend>, uri: &Uri) { }
}
}
Both methods have default no-op implementations, so you only need to override what you use.
Using Closures
For simple request-only middleware, you can pass a closure directly:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
req.headers_mut().insert(
http::header::HeaderName::from_static("x-custom"),
http::header::HeaderValue::from_static("value"),
);
})
.build()?;
}
Using a Struct
For middleware that needs to modify responses or maintain state, implement the trait on a struct:
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use aioduct::{TokioClient, RequestBodySend, Middleware};
struct RequestCounter {
count: Arc<AtomicU64>,
}
impl Middleware for RequestCounter {
fn on_request(&self, _req: &mut http::Request<RequestBodySend>, _uri: &http::Uri) {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
let counter = Arc::new(AtomicU64::new(0));
let client = TokioClient::builder()
.middleware(RequestCounter { count: counter.clone() })
.build()?;
}
Stacking Multiple Middleware
You can add multiple middleware layers. They execute in order:
- Request hooks run first-to-last (in the order they were added).
- Response hooks run last-to-first (reverse order).
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::builder()
.middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
// Runs first on request
req.headers_mut().insert(
http::header::HeaderName::from_static("x-trace-id"),
http::header::HeaderValue::from_static("abc123"),
);
})
.middleware(|req: &mut http::Request<aioduct::RequestBodySend>, _uri: &http::Uri| {
// Runs second on request
req.headers_mut().insert(
http::header::HeaderName::from_static("x-auth"),
http::header::HeaderValue::from_static("Bearer tok"),
);
})
.build()?;
}
When Middleware Runs
Middleware hooks run at these points in the request lifecycle:
- 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()
.http2_prior_knowledge()
.build()?;
let mut req = client
.get("http://example.com/ws/chat")?
.build();
*req.method_mut() = http::Method::CONNECT;
req.extensions_mut().insert(Protocol::from_static("websocket"));
let resp = client.execute(req).await?;
assert_eq!(resp.status(), http::StatusCode::OK);
let upgraded = resp.upgrade().await?;
// Bidirectional tunnel over the H2 stream
Ok(())
}
}
For proxy/gateway use cases, see Request Forwarding which auto-detects both upgrade mechanisms.
How It Works
- HTTP/1.1: Call
.upgrade()on theRequestBuilderto 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 anUpgradedstream. - The connection is not returned to the pool — it’s exclusively yours.
The Upgraded Type
Upgraded 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::UpgradedviaUpgraded::from()
Using with WebSocket Libraries
Pass the Upgraded stream to your WebSocket library of choice. For example, with tokio-tungstenite:
let upgraded = resp.upgrade().await?;
let ws_stream = tokio_tungstenite::WebSocketStream::from_raw_socket(
upgraded,
tokio_tungstenite::tungstenite::protocol::Role::Client,
None,
).await;
Notes
- HTTP/1.1 upgrades use
Connection: Upgrade+Upgrade: 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).
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 |
.h2c() | Force HTTP/2 prior knowledge (h2c) on this forward |
.adaptive_h2c() | Probe h2c, fall back to h1; result cached per-authority |
.upgrade() | Force upgrade header preservation (usually auto-detected) |
Hop-by-Hop Header Stripping
ForwardBuilder automatically strips these headers from both the incoming request and the upstream response:
ConnectionKeep-AliveProxy-AuthenticateProxy-AuthorizationProxy-ConnectionTETrailerTransfer-Encoding
Use .forward_header(name) to preserve specific headers through stripping.
WebSocket / HTTP Upgrade Forwarding
Upgrade requests are auto-detected and handled correctly:
HTTP/1.1 Upgrade
When Connection: Upgrade is present, ForwardBuilder:
- Preserves
ConnectionandUpgradeheaders through hop-by-hop stripping - Forces HTTP/1.1 on the upstream connection
- Skips response hop-by-hop stripping (101 is terminal)
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let ws_req = http::Request::builder()
.method("GET")
.uri("/ws/chat")
.header("connection", "Upgrade")
.header("upgrade", "websocket")
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
.header("sec-websocket-version", "13")
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client
.forward(ws_req)
.upstream("http://ws-backend:9000".parse::<http::Uri>().unwrap())
.send()
.await?;
assert_eq!(resp.status(), http::StatusCode::SWITCHING_PROTOCOLS);
// Get the bidirectional tunnel
let mut upstream_io = resp.upgrade().await?;
// In a real proxy, splice with downstream:
// tokio::io::copy_bidirectional(&mut downstream_io, &mut upstream_io).await?;
Ok(())
}
}
HTTP/2 Extended CONNECT (RFC 8441)
When the request method is CONNECT and a Protocol extension is present, ForwardBuilder:
- Forces HTTP/2 on the upstream connection
- Uses the full URI (not path-only) so hyper generates correct pseudo-headers
- Skips response hop-by-hop stripping
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, Protocol};
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::builder()
.http2_prior_knowledge()
.build()?;
let mut req = http::Request::builder()
.method(http::Method::CONNECT)
.uri("http://h2-backend:8080/ws/chat")
.body(Full::new(Bytes::new()))
.unwrap();
req.extensions_mut().insert(Protocol::from_static("websocket"));
let resp = client
.forward(req)
.upstream("http://h2-backend:8080".parse::<http::Uri>().unwrap())
.send()
.await?;
assert_eq!(resp.status(), http::StatusCode::OK);
let mut upstream_io = resp.upgrade().await?;
// Bidirectional tunnel is ready
Ok(())
}
}
Hooks
Use on_request and on_response for transformations not covered by other builder methods:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let incoming_req = http::Request::builder().uri("/test").body(Full::new(Bytes::new())).unwrap();
let resp = client
.forward(incoming_req)
.upstream("http://backend:8080".parse::<http::Uri>().unwrap())
.on_request(|parts| {
parts.headers.insert("x-request-id", "abc-123".parse().unwrap());
})
.on_response(|resp| {
resp.headers_mut().insert("x-proxy", "aioduct".parse().unwrap());
})
.send()
.await?;
Ok(())
}
}
gRPC / h2c Forwarding
For gRPC or other HTTP/2 cleartext (h2c) upstreams, use .h2c() to force HTTP/2 prior knowledge on an individual forward without requiring http2_prior_knowledge() on the entire client:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let grpc_req = http::Request::builder()
.method("POST")
.uri("/grpc.UserService/GetUser")
.header("content-type", "application/grpc")
.body(Full::new(Bytes::from("\0\0\0\0\x05hello")))
.unwrap();
let resp = client
.forward(grpc_req)
.upstream("http://grpc-backend:50051".parse::<http::Uri>().unwrap())
.h2c()
.send()
.await?;
Ok(())
}
}
Adaptive h2c
When you don’t know whether the upstream speaks h2c, use .adaptive_h2c(). On the first request to a given authority, it probes with an h2 prior knowledge handshake. If the upstream rejects it, the request falls back to HTTP/1.1 transparently. The result is cached per-authority so subsequent requests skip the probe:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use bytes::Bytes;
use http_body_util::Full;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let req = http::Request::builder()
.method("POST")
.uri("/api/data")
.body(Full::new(Bytes::new()))
.unwrap();
// First request probes; subsequent requests use cached result
let resp = client
.forward(req)
.upstream("http://backend:8080".parse::<http::Uri>().unwrap())
.adaptive_h2c()
.send()
.await?;
Ok(())
}
}
Configure the probe cache TTL (default 5 minutes) on the client:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use std::time::Duration;
let client = TokioClient::builder()
.h2c_probe_ttl(Duration::from_secs(600))
.build()?;
}
What ForwardBuilder Does NOT Do
- No body buffering — the body streams through as-is
- No middleware — redirects, cookies, cache, and decompression are all bypassed
- No WebSocket framing — aioduct is transport-level; use a WS library for frame parsing
- No bidirectional splice — the caller is responsible for splicing
Upgradedstreams - 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"));
}
Request Timings
aioduct records the duration of each connection phase for every request. Access the breakdown via Response::timings().
Usage
use aioduct::TokioClient;
#[tokio::main]
async fn main() -> Result<(), aioduct::Error> {
let client = TokioClient::with_rustls();
let resp = client
.get("https://httpbin.org/get")?
.send()
.await?;
if let Some(t) = resp.timings() {
if let Some(dns) = t.dns() {
println!("DNS: {:?}", dns);
}
if let Some(tcp) = t.tcp_connect() {
println!("TCP: {:?}", tcp);
}
if let Some(tls) = t.tls_handshake() {
println!("TLS: {:?}", tls);
}
if let Some(ttfb) = t.transfer() {
println!("Transfer: {:?}", ttfb);
}
println!("Total: {:?}", t.total());
}
Ok(())
}
Phases
| Accessor | Measures | None when |
|---|---|---|
dns() | Hostname resolution | Literal IP, pool hit, or proxy-handled DNS |
tcp_connect() | TCP connection establishment | Pool hit |
tls_handshake() | TLS handshake (rustls) | Plain HTTP or pool hit |
transfer() | Request sent → first response byte (TTFB) | Should not normally be None |
total() | Wall-clock start to response headers | Always present |
Pool Hits
When a request reuses an existing pooled connection, the connection setup phases (DNS, TCP, TLS) are skipped and reported as None. Only transfer() and total() are populated.
HTTP/3
HTTP/3 connections include QUIC connection establishment time. The TLS handshake is part of the QUIC handshake and is reflected in the tls_handshake() timing.
Problem Details (RFC 9457)
aioduct can parse RFC 9457 Problem Details responses — a standardized JSON format for HTTP API errors with the application/problem+json content type.
Requires the
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 workspace includes two command-line tools that serve as both useful utilities and real-world integration examples of the library.
aioduct-aria
An aria2-inspired parallel download tool. It probes the server for Accept-Ranges support, splits the file into segments, and downloads them concurrently.
Usage
# Basic download
aioduct-aria https://releases.example.com/archive.tar.gz
# 16 parallel segments, save to specific file
aioduct-aria -s 16 -o local.tar.gz https://releases.example.com/archive.tar.gz
# Resume an interrupted download
aioduct-aria -c https://releases.example.com/archive.tar.gz
# Limit download speed to 5 MB/s
aioduct-aria --max-download-speed 5M https://releases.example.com/archive.tar.gz
# Through a proxy with custom headers
aioduct-aria --proxy http://proxy:8080 -H "Authorization: Bearer tok" https://example.com/file
Features
- Segmented parallel downloads using HTTP Range requests
- Automatic filename detection from
Content-Dispositionor URL - Resume support (
-c) — skips already-downloaded segments - Progress bar with speed and ETA
- Bandwidth limiting
- Proxy support (HTTP and SOCKS5)
- Custom headers, basic auth, bearer auth
- Auto-rename to avoid overwriting existing files
aioduct-curl
A curl-inspired HTTP tool with familiar flags. Covers the most commonly used curl options.
Usage
# GET request
aioduct-curl https://httpbin.org/get
# POST with JSON
aioduct-curl -X POST -d '{"key":"val"}' -H 'Content-Type: application/json' https://httpbin.org/post
# HEAD request (show headers only)
aioduct-curl -I https://example.com
# Follow redirects with basic auth
aioduct-curl -L -u user:pass https://httpbin.org/basic-auth/user/pass
# Save response to file
aioduct-curl -o page.html https://example.com
# Verbose output
aioduct-curl -v https://example.com
# Write-out format (status code)
aioduct-curl -w '%{http_code}\n' -o /dev/null -s https://example.com
Supported Flags
| Flag | Long | Description |
|---|---|---|
-X | --request | HTTP method |
-d | --data | Request body (sets POST) |
--data-binary | Binary request body (supports @file) | |
-F | --form | 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 | Show request and response headers |
-s | --silent | Silent mode |
-S | --show-error | Show errors in silent mode |
-o | --output | Write to file |
-O | --remote-name | Save using filename from URL |
-D | --dump-header | Dump headers to file |
-w | --write-out | Format string (%{http_code}) |
-m | --max-time | Total request timeout (seconds) |
--connect-timeout | Connection timeout (seconds) | |
--retry | Retry count | |
-x | --proxy | Proxy URL |
-k | --insecure | Skip TLS verification |
--http2 | Force HTTP/2 | |
--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 failed |
| 22 | HTTP 4xx/5xx response |
| 23 | Write error |
| 28 | Timeout |
| 60 | TLS error |
Benchmarks
aioduct includes criterion benchmarks comparing HTTP client overhead against popular alternatives.
| Crate | Version | Description |
|---|---|---|
| aioduct | 0.2.0 | 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 H1 benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench h1
# All H2 benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench h2
# JSON benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench json
# Feature benchmarks (SSE, multipart, streaming, chunk download)
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench features
# Connection pool benchmarks
cargo bench --manifest-path crates/aioduct-bench/Cargo.toml --bench pooling
HTML reports are generated in target/criterion/.
Results
Measured on Linux 5.15, Rust 1.85, release profile. Times are the mean of 30–100 samples (lower is better).
HTTP/1.1 GET Request (bytes)
Simple GET, read entire response as Bytes.
| 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 |
|---|---|---|
h1 | benches/h1.rs | GET bytes/text, POST 4K, download 64K/1M, concurrent 10/50 |
h2 | benches/h2.rs | GET, download 64K/1M, concurrent 10, POST 4K |
json | benches/json.rs | JSON deserialization (GET + serde) |
features | benches/features.rs | SSE, multipart, upload 1M, chunk download, body stream |
pooling | benches/pooling.rs | H1/H2 with-pool vs no-pool |
API Reference
This page covers the main types and their methods. For full documentation, see cargo doc --features tokio,rustls,rustls-ring,json.
Client Types
aioduct provides ergonomic type aliases for the most common configurations:
| Type 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))
.max_redirects(5)
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(10)
.build()?;
}
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 |
|---|---|---|
timeout(Duration) | None | Default timeout for all requests |
connect_timeout(Duration) | None | Timeout for TCP connect + TLS handshake |
read_timeout(Duration) | None | Timeout between body data chunks |
tcp_keepalive(Duration) | None | Enable TCP keepalive with given interval |
local_address(IpAddr) | None | Bind outgoing connections to a local IP |
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_idle_per_host(usize) | 10 | Max idle connections per origin |
default_headers(HeaderMap) | User-Agent | Headers applied to every request |
no_default_headers() | — | Remove all default headers |
tls(RustlsConnector) | None | Custom TLS configuration |
danger_accept_invalid_certs() | — | Accept any TLS certificate (INSECURE) |
no_decompression() | — | Disable automatic response decompression |
system_proxy() | — | Read proxy from HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars |
proxy_settings(ProxySettings) | 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 |
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 |
RequestBuilderSend / RequestBuilderLocal
Fluent builder for configuring a single request. RequestBuilderSend is returned by HttpEngineSend methods; RequestBuilderLocal is returned by HttpEngineLocal methods. Both implement RequestBuilderExt.
Headers
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, HeaderMap};
let client = TokioClient::new();
// Typed header
use http::header::{HeaderName, HeaderValue, ACCEPT};
let rb = client.get("http://example.com").unwrap()
.header(ACCEPT, HeaderValue::from_static("application/json"));
// String header (fallible)
let rb = client.get("http://example.com").unwrap()
.header_str("x-custom", "value").unwrap();
// Bulk headers
let mut headers = HeaderMap::new();
headers.insert("x-a", "1".parse().unwrap());
headers.insert("x-b", "2".parse().unwrap());
let rb = client.get("http://example.com").unwrap()
.headers(headers);
}
Authentication
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Bearer token
let rb = client.get("http://example.com").unwrap()
.bearer_auth("my-token");
// Basic auth
let rb = client.get("http://example.com").unwrap()
.basic_auth("user", Some("password"));
}
Body
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Raw bytes
let rb = client.post("http://example.com").unwrap()
.body("raw body content");
// URL-encoded form
let rb = client.post("http://example.com").unwrap()
.form(&[("username", "admin"), ("password", "secret")]);
// JSON (requires `json` feature)
// let rb = client.post("http://example.com").unwrap()
// .json(&my_struct).unwrap();
}
Query Parameters
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
let rb = client.get("http://example.com/search").unwrap()
.query(&[("q", "hello world"), ("page", "1")]);
// Sends: GET /search?q=hello%20world&page=1
}
Other Options
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
use std::time::Duration;
let rb = client.get("http://example.com").unwrap()
.timeout(Duration::from_secs(5)) // per-request timeout
.version(http::Version::HTTP_11); // force HTTP version
// HTTP upgrade (WebSocket)
let rb = client.get("http://example.com/ws").unwrap()
.upgrade(); // sets Connection: Upgrade, Upgrade: websocket, HTTP/1.1
}
Sending
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
Ok(())
}
}
ResponseBodySend / ResponseBodyLocal
The response type returned after sending a request. ResponseBodySend is returned by HttpEngineSend; ResponseBodyLocal by HttpEngineLocal. Both implement ResponseExt.
Inspecting
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
let status = resp.status(); // StatusCode
let headers = resp.headers(); // &HeaderMap
let version = resp.version(); // Version
let length = resp.content_length(); // Option<u64>
let url = resp.url(); // &Uri — final URL after redirects
Ok(())
}
}
Error on Status
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// Consume the response, returning Err for 4xx/5xx
let resp = client.get("http://example.com")?.send().await?
.error_for_status()?;
// Non-consuming variant
let resp = client.get("http://example.com")?.send().await?;
resp.error_for_status_ref()?;
let text = resp.text().await?;
Ok(())
}
}
Consuming the Body
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// As bytes
let bytes = client.get("http://example.com")?.send().await?.bytes().await?;
// As string
let text = client.get("http://example.com")?.send().await?.text().await?;
// As JSON (requires `json` feature)
// let data: MyStruct = resp.json().await?;
// Raw hyper body
let body = client.get("http://example.com")?.send().await?.into_body();
// HTTP upgrade (WebSocket) — after 101 response
// let upgraded = resp.upgrade().await?;
Ok(())
}
}
Portable Traits
These traits provide a common interface across both Send and Local engine variants:
| 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) are automatically stripped when redirecting to a different origin.
Disable with .max_redirects(0) on the builder.
Error Types
#![allow(unused)]
fn main() {
use aioduct::Error;
// Error variants:
// Error::Http(_) — http crate errors
// Error::Hyper(_) — hyper protocol errors
// Error::Io(_) — I/O errors
// Error::Tls(_) — TLS errors
// Error::Pool(_) — connection pool errors
// Error::Timeout — request timed out
// Error::InvalidUrl(_) — URL parse or scheme errors
// Error::Status(_) — HTTP 4xx/5xx from error_for_status()
// Error::Other(_) — other boxed errors
}
Error Convenience Methods
| Method | Description |
|---|---|
is_closed() | Returns true if the error is a closed connection |
is_timeout() | Returns true if the error is a timeout |
is_connect() | Returns true if the error occurred during connect |