Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API Reference

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

Client Types

aioduct provides ergonomic type aliases for the most common configurations:

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

Construction

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

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

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

// Custom configuration via builder
let client = TokioClient::builder()
    .timeout(Duration::from_secs(30))
    .connect_timeout(Duration::from_secs(10))
    .max_redirects(5)
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_lifetime(Duration::from_secs(600))
    .pool_max_idle_per_host(10)
    .pool_max_active_per_host(64)
    .pool_max_active_streams_per_connection(100)
    .build()?;
}

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

HTTP Methods

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

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

HttpEngineBuilder Options

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

MethodDefaultDescription
base_url(&str)NoneBase URL that relative request URLs resolve against (RFC 3986)
timeout(Duration)NoneOverall request deadline
connect_timeout(Duration)NoneOne connection-acquisition deadline across pool coordination, DNS, transport, proxy, TLS, and protocol setup
read_timeout(Duration)NoneTimeout between response body chunks
write_timeout(Duration)NoneTimeout between request body chunks (upload)
tcp_keepalive(Duration)NoneEnable TCP keepalive with given interval
local_address(IpAddr)NoneBind outgoing connections to a local IP
address_family(AddressFamily)AnyRestrict/prefer IP family (Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6) for resolved connections
max_redirects(usize)10Maximum redirect hops (0 = disabled)
referer(bool)falseSet Referer header on redirects
https_only(bool)falseReject non-HTTPS URLs
pool_idle_timeout(Duration)90sIdle connection lifetime
pool_max_lifetime(Duration)NoneMaximum connection age before reuse stops
pool_max_idle_per_host(usize)10Max idle connections per origin
pool_max_active_per_host(usize)UnlimitedMax checked-out handles and fresh connection attempts per pool key; 0 disables the cap
pool_max_active_streams_per_connection(usize)UnlimitedMax active HTTP/2 or HTTP/3 streams per pooled connection
default_headers(HeaderMap)User-AgentHeaders applied to every request
no_default_headers()Remove all default headers
tls(RustlsConnector)NoneCustom rustls configuration, including caller-built ECH configs
danger_accept_invalid_certs()Accept any TLS certificate (INSECURE)
no_decompression()Disable automatic response decompression
system_proxy()Read proxy from HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars
proxy_settings(ProxySettings)NoneFine-grained HTTP/HTTPS proxy with bypass rules
http2(Http2Config)NoneConfigure HTTP/2 parameters (window sizes, keepalive, frame size)
middleware(impl Middleware)NoneAdd a middleware layer that can inspect/modify requests and responses
automatic_content_digest(bool)falseInsert SHA-256 Content-Digest for buffered native request bodies before automatic signing
message_signature(config, signer)NoneSync automatic RFC 9421 request signing for finalized native requests
message_signature_async(config, signer)NoneSend-runtime async automatic RFC 9421 request signing
message_signature_async_local(config, signer)NoneLocal-runtime async automatic RFC 9421 request signing
retry(RetryConfig)NoneDefault retry policy for all requests
cookie_jar(CookieJar)NoneEnable automatic cookie management
rate_limiter(RateLimiter)NoneToken-bucket rate limiter for outgoing requests
cache(HttpCache)NoneEnable in-memory HTTP response caching

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

Timeout Boundaries

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

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

RequestBuilderSend / RequestBuilderLocal

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

Headers

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

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

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

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

Authentication

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

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

Body

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

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

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

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

Query Parameters

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

Other Options

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

let rb = client.get("http://example.com").unwrap()
    .timeout(Duration::from_secs(5))     // per-request timeout
    .connect_timeout(Duration::from_secs(2)) // per-request connection timeout
    .read_timeout(Duration::from_secs(30))   // per-request response read-gap timeout
    .write_timeout(Duration::from_secs(10))  // per-request upload timeout
    .automatic_content_digest(true)          // per-request SHA-256 Content-Digest
    .no_decompression()                      // per-request: skip Accept-Encoding + decoding
    .version(http::Version::HTTP_11);    // force HTTP version

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

Inspecting a builder

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

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

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

HTTP Message Signatures

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

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

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

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

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

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

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

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

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

Native clients can also sign each finalized request attempt automatically:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sending

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

Error Handling

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

Timeout helpers distinguish the configured phases:

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

Reading the body of an error response

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

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

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

ResponseBodySend / ResponseBodyLocal

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

Inspecting

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

Error on Status

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

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

Consuming the Body

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

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

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

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

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

Blocking Client

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

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

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

Portable Traits

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

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

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

Redirects

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

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

Sensitive headers (Authorization, Cookie, Proxy-Authorization) and request headers whose HeaderValue is marked sensitive are automatically stripped when redirecting to a different origin.

Disable with .max_redirects(0) on the builder.

Request Lifecycle Observer

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

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

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

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

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

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

RequestPhase Variants

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

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

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

Trailers

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

Via the bytes stream

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

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

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

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

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

Via the raw body frame stream

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

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

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

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

Via the request observer

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

Error Types

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

// Error variants:
// Error::Http(_)         — http crate errors
// Error::Hyper(_)        — hyper protocol errors
// Error::Io(_)           — I/O errors
// Error::Tls(_)          — TLS errors
// Error::Pool(_)         — connection pool errors
// PoolLimitKind          — what pool limit was reached
// Error::Timeout         — request timed out
// Error::ConnectTimeout  — connection establishment timed out
// Error::ReadTimeout     — reading response timed out
// Error::WriteTimeout    — writing request body timed out
// Error::InvalidUrl(_)   — URL parse or scheme errors
// Error::InvalidHeader(_) — header name or value errors
// Error::Unsupported(_)  — runtime or transport does not support an option
// Error::Status(_)       — HTTP 4xx/5xx from error_for_status()
// Error::Other(_)        — other boxed errors
}

Error Convenience Methods

MethodDescription
is_closed()Returns true if the error is a closed connection
is_timeout()Returns true if the error is a timeout
is_write_timeout()Returns true if the error is an upload timeout
is_connect()Returns true if the error occurred during connect
is_pool_limit()Returns true if the error is a pool limit (client-side backpressure)