API Reference
This page covers the main types and their methods. For full documentation, see cargo doc --features tokio,rustls,rustls-ring,json.
Client Types
aioduct provides ergonomic type aliases for the most common configurations:
| Type Alias | Expands To | Runtime |
|---|---|---|
TokioClient | HttpEngineSend<TokioRuntime, tokio_rt::TcpConnector> | tokio |
SmolClient | HttpEngineSend<SmolRuntime, smol_rt::TcpConnector> | smol |
CompioClient | HttpEngineLocal<CompioRuntime, compio_rt::TcpConnector> | compio |
Construction
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use std::time::Duration;
// Default configuration
let client = TokioClient::new();
// With rustls TLS (requires `rustls` and exactly one rustls provider)
let client = TokioClient::with_rustls();
// Custom configuration via builder
let client = TokioClient::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.max_redirects(5)
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_lifetime(Duration::from_secs(600))
.pool_max_idle_per_host(10)
.pool_max_active_per_host(64)
.pool_max_active_streams_per_connection(100)
.auto_tune(aioduct::AutoTuneConfig::default())
.build()?;
}
Tokio, smol, and compio share the same native client builder surface. Blocking clients wrap an already-configured async or local client, so pool, timeout, TLS, proxy, retry, and HTTP/2 keep-alive settings are preserved by the wrapper. Wasm and wasi-p2 clients use platform-managed transports, so connection pooling, DNS, proxy, and TLS details are controlled by the browser or WASI host.
HTTP Methods
| Method | Description |
|---|---|
get(url) | Start a GET request |
head(url) | Start a HEAD request |
post(url) | Start a POST request |
put(url) | Start a PUT request |
patch(url) | Start a PATCH request |
delete(url) | Start a DELETE request |
request(method, url) | Start a request with any HTTP method |
All methods return Result<RequestBuilderSend> (or Result<RequestBuilderLocal> for HttpEngineLocal) — the URL is parsed immediately and invalid URLs produce an error.
HttpEngineBuilder Options
HttpEngineBuilder<R, C> is returned by TokioClient::builder(connector) (and similarly for other client types).
| Method | Default | Description |
|---|---|---|
base_url(&str) | None | Base URL that relative request URLs resolve against (RFC 3986) |
timeout(Duration) | None | Overall request deadline |
connect_timeout(Duration) | None | One connection-acquisition deadline across pool coordination, DNS, transport, proxy, TLS, and protocol setup |
read_timeout(Duration) | None | Timeout between response body chunks |
write_timeout(Duration) | None | Timeout between request body chunks (upload) |
tcp_keepalive(Duration) | None | Enable TCP keepalive with given interval |
local_address(IpAddr) | None | Bind outgoing connections to a local IP |
address_family(AddressFamily) | Any | Restrict/prefer IP family (Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6) for resolved connections |
max_redirects(usize) | 10 | Maximum redirect hops (0 = disabled) |
referer(bool) | false | Set Referer header on redirects |
https_only(bool) | false | Reject non-HTTPS URLs |
pool_idle_timeout(Duration) | 90s | Idle connection lifetime |
pool_max_lifetime(Duration) | None | Maximum connection age before reuse stops |
pool_max_idle_per_host(usize) | 10 | Max idle connections per origin |
pool_max_active_per_host(usize) | Unlimited | Max checked-out handles and fresh connection attempts per pool key; 0 disables the cap |
pool_max_active_streams_per_connection(usize) | Unlimited | Max active HTTP/2 or HTTP/3 streams per pooled connection |
default_headers(HeaderMap) | User-Agent | Headers applied to every request |
no_default_headers() | — | Remove all default headers |
tls(RustlsConnector) | None | Custom rustls configuration, including caller-built ECH configs |
danger_accept_invalid_certs() | — | Accept any TLS certificate (INSECURE) |
no_decompression() | — | Disable automatic response decompression |
netrc(Netrc) | None | Apply explicitly loaded credentials to matching native request hosts |
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) |
auto_tune(AutoTuneConfig) | Off | Use HTTP/1.1 for known request bodies at or above the configured threshold |
automatic_content_digest(bool) | false | Insert SHA-256 Content-Digest for buffered native request bodies before automatic signing |
message_signature(config, signer) | None | Sync automatic RFC 9421 request signing for finalized native requests |
message_signature_async(config, signer) | None | Send-runtime async automatic RFC 9421 request signing |
message_signature_async_local(config, signer) | None | Local-runtime async automatic RFC 9421 request signing |
retry(RetryConfig) | None | Default retry policy for all requests |
cookie_jar(CookieJar) | None | Enable automatic cookie management |
rate_limiter(RateLimiter) | None | Token-bucket rate limiter for outgoing requests |
cache(HttpCache) | None | Enable in-memory HTTP response caching |
auto_tune(AutoTuneConfig) is opt-in and deterministic. A request with an
exact body size at or above large_body_threshold (16 MiB by default) uses
HTTP/1.1. Unknown-length
streaming bodies and smaller bodies retain the normal Auto protocol choice;
explicit request protocol settings take precedence. The policy does not buffer,
split, retry, or duplicate request bodies. CONNECT and Upgrade requests bypass
this rule. It applies to native request execution; forward() and platform-managed
Wasm/WASI transports retain their existing protocol behavior. The endpoint must
accept HTTP/1.1: a protocol rejection is returned without an auto-tune fallback.
To change the threshold, use
AutoTuneConfig::default().large_body_threshold(64 * 1024 * 1024).
base_url(&str)returnsResultbecause it validates the URL eagerly; the other setters returnSelf. When a base URL is set, a relative request URL (e.g.client.get("users")) resolves against it per RFC 3986, while an absolute request URL overrides it.
Retry Boundaries
Configured retry applies to execution errors and response statuses before the
final response is selected. Once selected, response body reads, decoding, and
library processing errors fail directly; they do not invoke a custom retry
classifier. This also applies to eager cache pre-reading, which can make
send() return an error before exposing a response. Without a cache, the same
body failure may instead be returned by a later bytes() or stream read.
Returned streaming data is never transparently retried or spliced.
This rule is independent of whether observation is enabled. It removes the previous eager-cache-only opportunity to retry body failures, while preserving pre-selection execution/status retry and its replay, retry-count, and budget limits. Retry remains disabled unless configured.
Timeout Boundaries
| Timeout | Phase covered | Phase not covered |
|---|---|---|
timeout(Duration) | One request attempt until send() returns, including redirects, response headers, and body upload | Retry backoff, later retry attempts, and response body reads after send() returns |
connect_timeout(Duration) | Pool coordination, DNS, TCP or QUIC setup, every proxy hop, TLS, and HTTP/2 or HTTP/3 establishment on a pool miss | Pooled request dispatch, request upload, response headers after dispatch, and response body reads |
read_timeout(Duration) | Gaps between response body chunks | Waiting for response headers and request upload |
write_timeout(Duration) | Gaps while uploading request body chunks | Waiting for response headers and response body reads |
Per-request timeout setters override client defaults. no_timeout() disables a
client-level overall request timeout for one request, while phase-specific
timeouts still apply if configured on that request. When retries are enabled,
this timeout applies per attempt; it does not cap total wall-clock time across
backoff sleeps and later attempts. Use read_timeout() to bound stalled response
body reads after send() returns.
RequestBuilderSend / RequestBuilderLocal
Fluent builder for configuring a single request. RequestBuilderSend is returned by HttpEngineSend methods; RequestBuilderLocal is returned by HttpEngineLocal methods. Both implement RequestBuilderExt. Fluent setters that cannot fail immediately record the error and return it from build() or send().
Headers
#![allow(unused)]
fn main() {
use aioduct::{TokioClient, HeaderMap};
let client = TokioClient::new();
// Typed header
use http::header::{HeaderName, HeaderValue, ACCEPT};
let rb = client.get("http://example.com").unwrap()
.header(ACCEPT, HeaderValue::from_static("application/json"));
// String header (fallible)
let rb = client.get("http://example.com").unwrap()
.header_str("x-custom", "value").unwrap();
// Bulk headers
let mut headers = HeaderMap::new();
headers.insert("x-a", "1".parse().unwrap());
headers.insert("x-b", "2".parse().unwrap());
let rb = client.get("http://example.com").unwrap()
.headers(headers);
}
Typed HeaderValue metadata is preserved. If a request header value is marked
sensitive with set_sensitive(true), aioduct strips that header on cross-origin
redirects in addition to the built-in Authorization, Cookie, and
Proxy-Authorization stripping.
Authentication
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Bearer token
let rb = client.get("http://example.com").unwrap()
.bearer_auth("my-token");
// Basic auth
let rb = client.get("http://example.com").unwrap()
.basic_auth("user", Some("password"));
}
Body
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
// Raw bytes
let rb = client.post("http://example.com").unwrap()
.body("raw body content");
// URL-encoded form
let rb = client.post("http://example.com").unwrap()
.form(&[("username", "admin"), ("password", "secret")]);
// Multipart form-data
// Generated boundaries are RFC 2046-safe and at most 70 bytes.
// let rb = client.post("http://example.com").unwrap()
// .multipart(aioduct::Multipart::new().text("field", "value"));
//
// Custom boundary (validated, 1-70 RFC 2046 chars) and subtype:
// let form = aioduct::Multipart::new()
// .with_boundary("WebKitFormBoundaryABC123")? // Result
// .subtype("mixed")? // multipart/mixed
// .text("field", "value");
// JSON (requires `json` feature)
// let rb = client.post("http://example.com").unwrap()
// .json(&my_struct).unwrap();
}
Query Parameters
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
let rb = client.get("http://example.com/search").unwrap()
.query(&[("q", "hello world"), ("page", "1")]);
// Sends: GET /search?q=hello%20world&page=1
}
Other Options
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
use std::time::Duration;
let rb = client.get("http://example.com").unwrap()
.timeout(Duration::from_secs(5)) // per-request timeout
.connect_timeout(Duration::from_secs(2)) // per-request connection timeout
.read_timeout(Duration::from_secs(30)) // per-request response read-gap timeout
.write_timeout(Duration::from_secs(10)) // per-request upload timeout
.automatic_content_digest(true) // per-request SHA-256 Content-Digest
.no_decompression() // per-request: skip Accept-Encoding + decoding
.version(http::Version::HTTP_11); // force HTTP version
// HTTP upgrade (WebSocket)
let rb = client.get("http://example.com/ws").unwrap()
.upgrade(); // sets Connection: Upgrade, Upgrade: websocket, HTTP/1.1
}
Inspecting a builder
Read accessors let you inspect a configured request before sending it (e.g. for
logging, signing, or library wrappers). method_ref() returns the method,
url() the resolved URL, and headers_ref() the headers added so far (client
default headers are merged at send time, so they are not reflected here). Call
build() to get the full http::Request without sending.
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let client = TokioClient::new();
let rb = client.post("http://example.com/api").unwrap()
.header(http::header::ACCEPT, http::HeaderValue::from_static("application/json"));
assert_eq!(rb.method_ref(), &http::Method::POST);
assert_eq!(rb.url().to_string(), "http://example.com/api");
assert!(rb.headers_ref().contains_key(http::header::ACCEPT));
}
The builders are #[must_use]: a RequestBuilderSend or
RequestBuilderLocal that is never sent or built, or an HttpEngineBuilder
that is never built, produces a compiler warning.
HTTP Message Signatures
MessageSignatureConfig builds RFC 9421 request signature bases and formats the
Signature-Input / Signature headers from caller-provided signature bytes.
MessageSignature parses existing signature fields by label and rebuilds the
request or response signature base for caller-owned verification. The context
helpers support caller-supplied trailer field components with ;tr.
MessageSignatureVerificationPolicy applies request verification policy checks
before invoking caller-owned cryptographic verification. When body bytes are
attached to verification contexts, the policy also verifies covered SHA-256
Content-Digest fields before calling the verifier. The helpers are portable and
do not choose a cryptographic algorithm.
#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, Method, Uri};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let headers = HeaderMap::new();
let config = MessageSignatureConfig::new("sig1")?
.component(MessageSignatureComponent::method())
.component(MessageSignatureComponent::authority())
.created(1_618_884_473)
.key_id("test-key");
let base = config.signature_base(&Method::GET, &target_uri, &request_target, &headers)?;
let signature = sign_with_your_key(base.as_bytes());
let signature_headers = config.headers_from_signature(signature)?;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}
Response bases can cover @status, response fields, and selected related
request components with ;req:
#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig};
use http::{HeaderMap, Method, StatusCode, Uri};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let request_headers = HeaderMap::new();
let response_headers = HeaderMap::new();
let config = MessageSignatureConfig::new("reqres")?
.component(MessageSignatureComponent::status())
.component(MessageSignatureComponent::method().related_request())
.created(1_618_884_479);
let base = config.request_response_signature_base(
&Method::POST,
&target_uri,
&request_target,
&request_headers,
StatusCode::OK,
&response_headers,
)?;
let _ = base;
Ok(())
}
}
Use MessageSignatureRequestContext::with_trailers(...) or
MessageSignatureResponseContext::with_trailers(...) with
signature_base_for_request_context(), response_signature_base_for_context(),
or request_response_signature_base_for_context() when a signature covers
caller-supplied trailer fields with ;tr.
Native clients can also sign each finalized request attempt automatically:
#![allow(unused)]
fn main() {
use aioduct::{MessageSignatureComponent, MessageSignatureConfig, TokioClient};
fn example() -> Result<(), Box<dyn std::error::Error>> {
let config = MessageSignatureConfig::new("sig1")?
.component(MessageSignatureComponent::method())
.component(MessageSignatureComponent::authority())
.key_id("test-key");
let client = TokioClient::builder()
.message_signature(config, |base: &[u8]| Ok(sign_with_your_key(base)))
.build()?;
let _ = client;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}
Automatic signing runs after default headers, cookies, cache validators,
digest-auth retry headers, forwarding request rewrites, and framing
cleanup. When configured, it replaces only its configured label in
Signature-Input and Signature on every native dispatch attempt.
Use message_signature_async(...) for send-runtime signers that return Send
futures, or message_signature_async_local(...) for local-runtime signers whose
future does not need to be Send. Async signers receive an owned
MessageSignatureBase, so request/header borrows are not held across the signer
await point.
Forward builders can sign the downstream response with
response_message_signature(...), response_message_signature_async(...), or
response_message_signature_async_local(...). Forward response signing runs
after upstream response hop-by-hop cleanup and on_response, preserves unrelated
signature labels, replaces its configured label, and uses the inbound request
snapshot for related-request components. Use downstream_target_uri(...) when an
origin-form inbound request needs related-request @scheme, @authority, or
@target-uri coverage.
Forward builders can also call response_content_digest(max_bytes) to buffer the
downstream response body up to a caller-supplied cap and insert SHA-256
Content-Digest before response signing. Existing Content-Digest fields are
preserved without buffering, bodyless responses such as HEAD, 204, 205, and
304 are skipped, and bodies over the cap fail closed.
Use automatic_content_digest(true) on the client builder or a request builder
to insert Content-Digest: sha-256=:...: for buffered native request bodies that
do not already have Content-Digest. The header is generated after request preparation
and before automatic signing, so signatures covering content-digest cover the
generated value. Requests without a configured body are left unchanged. Streaming
bodies are not buffered; provide Content-Digest
explicitly for those requests. Use sha256_content_digest_value(...) for an
in-memory body, or sha256_content_digest_value_from_digest(...) after hashing a
streaming body out-of-band.
AcceptSignature parses and formats RFC 9421 Accept-Signature negotiation
fields. AcceptSignatureFulfillment turns accepted entries into concrete
MessageSignatureConfig values after validating the target shape and requested
metadata:
#![allow(unused)]
fn main() {
use aioduct::{
AcceptSignature, AcceptSignatureEntry, AcceptSignatureFulfillment,
MessageSignatureComponent,
};
use http::{HeaderMap, StatusCode};
fn example(mut headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let accept = AcceptSignature::new().entry(
AcceptSignatureEntry::new("sig1")?
.component(MessageSignatureComponent::status())
.created()
.key_id("test-key"),
);
let configs = accept.response_signature_configs(
&AcceptSignatureFulfillment::new()
.created(1_618_884_500)
.key_id("test-key"),
)?;
let signature_headers = configs[0].sign_response(
StatusCode::OK,
&headers,
&|base: &[u8]| Ok(sign_with_your_key(base)),
)?;
signature_headers.insert_into(&mut headers)?;
Ok(())
}
fn sign_with_your_key(_: &[u8]) -> Vec<u8> { vec![1, 2, 3] }
}
AcceptSignature::from_headers() parses combined field values. The fulfillment
helpers do not choose keys or algorithms and do not automatically decide which
requests to honor; callers still generate timestamps, run cryptography, and
attach Signature-Input / Signature fields.
For verification, configure a policy, then pass the selected label, rebuilt base, signature bytes, and metadata to your own verifier:
#![allow(unused)]
fn main() {
use aioduct::{
MessageSignatureComponent, MessageSignatureVerificationInput,
MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, Uri};
fn example(headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let policy = MessageSignatureVerificationPolicy::new()
.required_component(MessageSignatureComponent::method())
.accepted_algorithm("ed25519")
.accepted_key_id("test-key")
.validation_time(1_618_884_500)
.max_age(300);
policy.verify_request(
&headers,
"sig1",
&Method::GET,
&target_uri,
&request_target,
&|input: MessageSignatureVerificationInput<'_>| {
Ok(verify_with_your_key(
input.signature_base(),
input.signature(),
input.params(),
))
},
)?;
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8], _: &aioduct::MessageSignatureParams) -> bool {
true
}
}
If the selected signature covers content-digest and you have the body bytes,
use MessageSignatureRequestContext::new(...).with_body(body) with
verify_request_context(...). For responses, attach bytes with
MessageSignatureResponseContext::new(...).with_body(body). Mismatched,
malformed, or unsupported digest fields fail before the verifier callback runs;
contexts without body bytes keep the previous signature-only behavior. If the
selected signature covers trailer fields with ;tr, attach trailer maps with
with_trailers(...); missing trailer maps fail before the verifier callback
runs.
Response verification uses borrowed context values so signatures can cover both the response and selected related request components:
#![allow(unused)]
fn main() {
use aioduct::{
MessageSignatureComponent, MessageSignatureRequestContext,
MessageSignatureResponseContext, MessageSignatureVerificationInput,
MessageSignatureVerificationPolicy,
};
use http::{HeaderMap, Method, StatusCode, Uri};
fn example(request_headers: HeaderMap, response_headers: HeaderMap) -> Result<(), Box<dyn std::error::Error>> {
let target_uri: Uri = "https://example.com/api".parse()?;
let request_target: Uri = "/api".parse()?;
let request = MessageSignatureRequestContext::new(
&Method::POST,
&target_uri,
&request_target,
&request_headers,
);
let response = MessageSignatureResponseContext::new(StatusCode::OK, &response_headers);
let policy = MessageSignatureVerificationPolicy::new()
.required_component(MessageSignatureComponent::status())
.required_component(MessageSignatureComponent::method().related_request())
.validation_time(1_618_884_500);
policy.verify_request_response(
request,
response,
"sig1",
&|input: MessageSignatureVerificationInput<'_>| {
Ok(verify_with_your_key(
input.signature_base(),
input.signature(),
input.params(),
))
},
)?;
Ok(())
}
fn verify_with_your_key(_: &[u8], _: &[u8], _: &aioduct::MessageSignatureParams) -> bool {
true
}
}
If the selected signature includes created or expires, set
validation_time(); otherwise the policy fails closed with
MissingValidationTime instead of silently accepting stale metadata.
MessageSignature::verify_response() and verify_request_response() apply the
same policy to an already parsed signature.
Sending
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
Ok(())
}
}
Error Handling
Async send() returns SendError on failure. It keeps the redacted request URL
next to the underlying Error, exposes helpers such as is_timeout(),
is_connect(), is_status(), and status(), and implements source() so
standard error-chain traversal works. Error::root_cause() and
SendError::root_cause() return the deepest source, and display output includes
hidden nested causes for boxed TLS or catch-all errors when the outer message
would otherwise omit the useful detail.
Timeout helpers distinguish the configured phases:
| Error | Typical source |
|---|---|
Timeout | Overall request deadline from timeout() |
ConnectTimeout | Connection establishment deadline from connect_timeout() |
ReadTimeout | Gap between response body chunks from read_timeout() |
WriteTimeout | Gap between request body chunks from write_timeout() |
Reading the body of an error response
error_for_status() turns a 4xx/5xx into Error::Status(code) and does not
capture the body, so the error value stays cheap. When you need the error
payload (an API’s JSON error message, for example), read it as a separate stage:
status() is a synchronous, non-consuming check, so gate on it and then read the
body yourself.
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com/api")?.send().await?;
if resp.status().is_client_error() || resp.status().is_server_error() {
let status = resp.status();
let body = resp.text().await?; // read the error body as its own stage
eprintln!("server said {status}: {body}");
return Ok(());
}
let body = resp.text().await?; // success path
let _ = body;
Ok(())
}
}
error_for_status_ref() borrows instead of consuming, so you can check status
without giving up ownership of the response, then read the body on either branch.
Status and body are intentionally decoupled, and reading the body consumes the
response (no implicit buffering), matching reqwest’s and aiohttp’s model.
ResponseBodySend / ResponseBodyLocal
The response type returned after sending a request. ResponseBodySend is returned by HttpEngineSend; ResponseBodyLocal by HttpEngineLocal. Both implement ResponseExt.
Inspecting
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
let resp = client.get("http://example.com")?.send().await?;
let status = resp.status(); // StatusCode
let headers = resp.headers(); // &HeaderMap
let version = resp.version(); // Version
let length = resp.content_length(); // Option<u64>
let url = resp.url(); // &Uri — final URL after redirects
Ok(())
}
}
Error on Status
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// Consume the response, returning Err for 4xx/5xx
let resp = client.get("http://example.com")?.send().await?
.error_for_status()?;
// Non-consuming variant
let resp = client.get("http://example.com")?.send().await?;
resp.error_for_status_ref()?;
let text = resp.text().await?;
Ok(())
}
}
Consuming the Body
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
async fn example() -> Result<(), aioduct::Error> {
let client = TokioClient::new();
// As bytes
let bytes = client.get("http://example.com")?.send().await?.bytes().await?;
// As string
let text = client.get("http://example.com")?.send().await?.text().await?;
// As JSON (requires `json` feature)
// let data: MyStruct = resp.json().await?;
// Raw hyper body
let body = client.get("http://example.com")?.send().await?.into_body();
// HTTP upgrade (WebSocket) — after 101 response
// let upgraded = resp.upgrade().await?;
Ok(())
}
}
Blocking Client
With the blocking feature enabled, BlockingTokioClient wraps TokioClient
for synchronous callers. BlockingResponse exposes the same buffered consumers
as async responses and native response metadata accessors before body
consumption. Blocking request builders forward the common buffered request-body
and per-request behavior controls, including body(), form(), timeout(),
read_timeout(), connect_timeout(), no_decompression(), query(), and
version().
#![allow(unused)]
fn main() {
#[cfg(all(feature = "blocking", feature = "tokio"))]
fn example() -> Result<(), aioduct::Error> {
use aioduct::{BlockingTokioClient, TokioClient};
let client = BlockingTokioClient::new(TokioClient::new());
let mut resp = client.post("http://example.com/")?
.form(&[("name", "alice")])
.no_decompression()
.send()?;
resp.headers_mut().insert("x-local", "yes".parse().unwrap());
let body = resp.bytes()?;
Ok(())
}
}
Portable Traits
These traits provide a common interface across client types. Implementations must either apply each request-builder option or fail explicitly when the request is sent; unsupported options are not silently ignored.
| Trait | Description |
|---|---|
HttpClient | Common client interface (get, post, etc.) |
RequestBuilderExt | Common request builder methods (header, body, etc.) |
ResponseExt | Common response methods (status, text, bytes, etc.) |
ByteStreamExt | Streaming body helpers |
Use these traits to write generic code that works with any aioduct client type.
ByteStreamExt names its reading future through type Next<'a>; normal calls
remain stream.next().await. A generic task that must be Send can require
S: ByteStreamExt + Send and for<'a> S::Next<'a>: Send. The base trait does not
force local or host-managed streams to be Send. The higher-ranked bound has a
current compiler limitation that can imply 'static for third-party streams
borrowing external data; aioduct’s self-contained stream types avoid that case.
The named futures have private fields and no public layout guarantee. WASI’s
trait future still performs the existing synchronous host read when polled.
Redirects
aioduct follows redirects automatically (up to max_redirects, default 10):
| Status | Behavior |
|---|---|
| 301 | Follow with GET, drop body + content headers |
| 302 | Follow with GET, drop body + content headers |
| 303 | Follow with GET, drop body + content headers |
| 307 | Follow with original method + body |
| 308 | Follow with original method + body |
Sensitive headers (Authorization, Cookie, Proxy-Authorization) and request headers whose HeaderValue is marked sensitive are automatically stripped when redirecting to a different origin.
Disable with .max_redirects(0) on the builder.
Request Lifecycle Observer
The native 0.3 request-level API uses an Observer provider on the client and
one RequestObservation per executing logical request. Retries and redirects
reuse that observation. The response, byte-stream, and SSE types remain
independent of the observer type.
#![allow(unused)]
fn main() {
use aioduct::{Interest, Observer, RequestObservation, RequestInfo,
RequestFinish, TokioClient};
struct Metrics;
struct RequestMetrics { request_id: Option<u64> }
impl Observer for Metrics {
type Context = u64;
type Observation = RequestMetrics;
type ConnectionObservation = aioduct::NoopConnectionObservation;
fn begin_connection(&self) -> (Interest, Self::ConnectionObservation) {
(Interest::EMPTY, aioduct::NoopConnectionObservation)
}
fn begin(&self, _: RequestInfo<'_>, context: Option<u64>)
-> (Interest, RequestMetrics)
{
(Interest::REQUEST_FINISH | Interest::RESPONSE_BODY_BYTES,
RequestMetrics { request_id: context })
}
}
impl RequestObservation for RequestMetrics {
fn on_request_finish(&self, event: &RequestFinish<'_>) {
// Export event.reason, response, attempt_count and original_data_bytes
// together with self.request_id to your chosen backend.
}
}
let client = TokioClient::builder().observer(Metrics).build()?;
let response: aioduct::Response = client.get("https://example.com/")?
.observation_context(42).send().await?;
let bytes = response.bytes().await?;
}
Observation is enabled by default, including when context is absent.
.disable_observation() on a request skips begin; returning Interest::EMPTY
from begin creates no continuation. Original DATA summaries are opt-in and
do not enable per-chunk callbacks. The finish event follows actual body
completion, error, abandonment, or successful channel handoff, not merely
receipt of headers. Callbacks are synchronous, receive &self, and run outside
aioduct’s internal locks. Exporting and fan-out are the implementation’s job.
Implementation status: native Send/Local requests expose the request start, selected headers, typed attempt and acquisition facts, upload/body terminal facts, independent channel handoff/I/O, connection establishment/lifecycle, finish summaries, original DATA summaries, and independent SSE lifecycle/statistics callbacks. The response, body-stream, SSE, and upgraded channel types do not carry the observer type. Host-managed browser/WASI paths remain subject to their documented transport visibility limits. See the complete runtime-specific references for local executables that exercise the native callback families together: Tokio, smol, and compio.
Independent SSE observations
Select Interest::SSE_LIFECYCLE for on_response_sse(ResponseSseEvent) callbacks:
Started at conversion, original borrowed Error, reliable Ended when no next
event remains, and Abandoned when the unfinished stream is dropped. An error
does not itself end SSE or prevent parser recovery. Cancelling one next() future
does not abandon the still-owned stream. There are no successful-message, Pending,
per-read, or per-comment callbacks.
Interest::SSE_STATISTICS internally enables this lifecycle and attaches optional
SseStatisticsSnapshot references to error/end/abandonment events, not Started.
It records delivered Message events, actually parsed comment lines, accepted retry
fields, and pending/peak parser input lengths. These are protocol facts, not
application success, HTTP retry counts, message sizes, or total-memory metrics.
Lifecycle-only events carry None; collected but unused counters are real zeros.
HTTP/body and SSE finish independently. A cache pre-read or the last body frame can complete HTTP while SSE still has buffered events to deliver. Dropping that SSE stream must not change the completed HTTP result. Library body errors and SSE errors borrow the same original error; decoder errors do not rewrite HTTP.
Generic async code can use SseResponseExt::into_sse_stream and SseStreamExt.
Its named Next<'a> future supports an optional for<'a> S::Next<'a>: Send
bound, without imposing it on Local consumers. Blocking SSE reuses this associated
stream and its persistent runtime session; it has no second parser or observer.
Browser/WASI implement these extension traits too, but their new Observer integration
is still pending. Browser pending reads remain owned by each next future; WASI’s
trait future still drives the existing synchronous host read.
Trailers
HTTP trailers are optional header fields sent after the body in chunked transfer encoding. They are available through three channels:
Via the bytes stream
The simplest approach: drain the body with into_bytes_stream() and call
trailers() once the stream is exhausted. Trailers are captured
automatically as non-data frames are consumed.
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
let resp = client
.get("https://example.com/api")?
.send()
.await?;
let mut stream = resp.into_bytes_stream();
while let Some(chunk) = stream.next().await {
let _bytes = chunk?;
// process body data …
}
// Trailers are available after the stream is fully consumed
if let Some(trailers) = stream.trailers() {
for (name, value) in trailers.iter() {
println!("trailer {name}: {value:?}");
}
}
}
Via the raw body frame stream
For lower-level control, iterate the body frames directly:
#![allow(unused)]
fn main() {
use aioduct::TokioClient;
use http_body_util::BodyExt;
let resp = client
.get("https://example.com/api")?
.send()
.await?;
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame?;
if frame.is_trailers() {
let trailers = frame.into_trailers().unwrap();
for (name, value) in trailers.iter() {
println!("{name}: {value:?}");
}
}
}
}
Error Types
#![allow(unused)]
fn main() {
use aioduct::Error;
// Error variants:
// Error::Http(_) — http crate errors
// Error::Hyper(_) — hyper protocol errors
// Error::Io(_) — I/O errors
// Error::Tls(_) — TLS errors
// Error::Pool(_) — connection pool errors
// PoolLimitKind — what pool limit was reached
// Error::Timeout — request timed out
// Error::ConnectTimeout — connection establishment timed out
// Error::ReadTimeout — reading response timed out
// Error::WriteTimeout — writing request body timed out
// Error::InvalidUrl(_) — URL parse or scheme errors
// Error::InvalidHeader(_) — header name or value errors
// Error::Unsupported(_) — runtime or transport does not support an option
// Error::Status(_) — HTTP 4xx/5xx from error_for_status()
// Error::Other(_) — other boxed errors
}
Error Convenience Methods
| Method | Description |
|---|---|
is_closed() | Returns true if the error is a closed connection |
is_timeout() | Returns true if the error is a timeout |
is_write_timeout() | Returns true if the error is an upload timeout |
is_connect() | Returns true if the error occurred during connect |
is_pool_limit() | Returns true if the error is a pool limit (client-side backpressure) |