ugi 0.1.0

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation

ugi

A runtime-agnostic Rust HTTP client with full protocol coverage and a uniform builder API.

let client = ugi::Client::builder().build()?;
let resp = client.get("https://api.example.com/users/1").await?;
let user: User = resp.json().await?;

Features

Feature Detail
Protocols HTTP/1.1, HTTP/2, HTTP/3, H2C upgrade, WebSocket, SSE, gRPC
Compression Auto-negotiate gzip / brotli / deflate / zstd; manual override
Connection pooling Per-host keep-alive, configurable pool limits
TLS async-tls (rustls) by default; optional native-tls; cert pinning
Redirects Follow with configurable hop limit; strip Authorization on cross-origin
Cookies CookieJar middleware with domain/path/expiry semantics
Retry Per-request RetryPolicy with exponential back-off
Progress Upload and download progress callbacks
Proxy HTTP and SOCKS5 proxies with optional auth
File download Parallel byte-range chunks, integrity hashing, rate limiting
Rate limiting Token-bucket RateLimiter; per-request or shared across requests
Runtime Works with Tokio, smol, or bare block_on — no runtime lock-in

Implementation

These are the optimizations that separate a production HTTP client from a toy — ugi implements all of them.

Optimization What ugi does
TCP_NODELAY Set on every connection; disables Nagle's algorithm for request/response workloads where coalescing small packets adds latency rather than saving bandwidth
Happy Eyeballs (RFC 6555) Races IPv4 and IPv6 connections in parallel with a 250 ms IPv6 head-start; uses whichever wins, eliminating the long fallback delay on dual-stack hosts
TTL-aware DNS cache Resolved addresses are cached per their DNS TTL and reused across requests; avoids redundant lookups on every call
Connection pooling Per-host idle connection pool (max_idle_per_host = 8, idle_timeout = 90 s); TCP and TLS handshakes are paid once
Stale connection detection Pooled connections that have been silently closed by the server are detected and the request is transparently retried on a fresh connection
TLS session resumption rustls maintains an in-memory session cache by default; TLS 1.3 resumption eliminates the handshake round-trip on reconnect
HTTP/2 multiplexing Multiple concurrent requests share one TCP+TLS connection; eliminates head-of-line blocking and connection setup overhead
H2 PING keepalive Periodic PING frames keep HTTP/2 connections alive through NAT and idle-kill firewalls; configurable interval and ACK timeout
H2C prior-knowledge upgrade Speaks HTTP/2 in cleartext directly (RFC 7540 §3.4) without the round-trip cost of an HTTP/1.1 Upgrade handshake
ALPN negotiation Advertises h2 during TLS handshake so the server can confirm HTTP/2 without an extra round-trip
QUIC 0-RTT HTTP/3 requests resume with 0-RTT early data on cached QUIC session tickets, eliminating the TLS handshake round-trip on reconnect
Automatic compression Sends Accept-Encoding: gzip, deflate, br, zstd and transparently decodes the response; reduces transfer size without any user code
Idempotent-safe retry Retries on transient errors only for safe methods (GET, HEAD, OPTIONS, TRACE); never retries mutations to avoid duplicate side-effects; exponential back-off (100 ms → 5 s cap) between attempts
Progress throttling Progress callbacks are gated by a minimum time interval and a minimum byte delta; prevents callback overhead from dominating fast transfers
Token-bucket rate limiting Per-request or shared RateLimiter caps outbound bandwidth without busy-looping; works across concurrent requests
WebSocket kernel flush Every WebSocket frame is flushed to the kernel after write_all; ensures frames are not held in the OS socket buffer when the TLS layer is involved
Alt-Svc / HTTP/3 upgrade Parses Alt-Svc response headers (including draft versions h3-*) and opportunistically upgrades subsequent Auto-policy requests to HTTP/3 for origins that advertise it; entries respect the ma max-age and clear directive

Performance

Benchmarked on loopback (Apple M-series, cargo bench):

Scenario ugi competitor Delta
HTTP/1.1 keep-alive (2000 × GET) ~40,000 req/s reqwest ~34,000 req/s ugi ~18% faster
HTTP/2 keep-alive (50 × GET) ~12,000 req/s reqwest ~10,000 req/s ugi ~20% faster
gRPC unary JSON (200 × call) ~17,000 rps raw h2 crate ~21,000 rps 1.3× over bare transport
HTTP/3 QUIC unary GET (100 × GET) ~13,000 rps raw quiche ~17,000 rps 1.3× over bare transport
WebSocket echo (500 × msg) ~21,000 ops/s tokio-tungstenite ~55,000 ops/s tungstenite ~2.6× faster

The WebSocket gap is an async-runtime ecosystem difference (async-io vs tokio reactor), not a protocol correctness issue. The gRPC baseline is the raw h2 crate with no middleware, builder, or JSON encode/decode — measuring ugi's builder + codec overhead. Full methodology and optimization notes in docs/performance.md.

Quick start

Add to Cargo.toml:

[dependencies]
ugi = "0.1"

The default feature set is full-featured (rustls + h2 + brotli + zstd). Opt out of heavy codecs or switch TLS back-ends as needed:

# System TLS instead of rustls, no brotli/zstd
ugi = { version = "0.1", default-features = false, features = ["native-tls", "h2"] }

Cargo features

Feature Default Description
rustls yes TLS via async-tls / rustls (no OpenSSL dependency)
native-tls no TLS via OS certificate store (mutually exclusive with rustls)
h2 yes HTTP/2 support
h3 no HTTP/3 / QUIC via quiche (vendored BoringSSL, slow to compile)
brotli yes Brotli response decompression
zstd yes Zstandard response decompression

gzip and deflate are always available (lightweight via flate2). WebSocket, SSE, gRPC, and file download are always compiled in.

Runtime compatibility

// Tokio
#[tokio::main]
async fn main() -> ugi::Result<()> {
    let resp = ugi::get("https://httpbin.org/get").await?;
    println!("{}", resp.text().await?);
    Ok(())
}

// smol
fn main() -> ugi::Result<()> {
    smol::block_on(async {
        let resp = ugi::get("https://httpbin.org/get").await?;
        println!("{}", resp.text().await?);
        Ok(())
    })
}

// No runtime dependency — futures-lite block_on as a single-threaded executor.
// The async block only handles I/O; the result surfaces to synchronous code.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = futures_lite::future::block_on(async {
        ugi::get("https://httpbin.org/get").await?.text().await
    })?;
    println!("{text}");
    Ok(())
}

API overview

All builders share the same pattern: chain options, then .await (or call .save / .connect / .probe).

Client construction

let client = ugi::Client::builder()
    .base_url("https://api.example.com")?
    .bearer_auth("token")?
    .timeout(Duration::from_secs(30))
    .retry(RetryPolicy::Limit(3))
    .build()?;

ClientBuilder — connection and defaults

Method Description
base_url(url) Prefix for relative request URLs
header(name, value) / headers(iter) Default headers sent on every request
cookie(name, value) / cookies(iter) Static cookies sent on every request
user_agent(value) Set user-agent header
bearer_auth(token) Set authorization: Bearer …
basic_auth(user, pass) Set authorization: Basic …
timeout(dur) Total request timeout
connect_timeout(dur) TCP / TLS handshake timeout
read_timeout(dur) Per-read idle timeout
write_timeout(dur) Per-write idle timeout
redirect(RedirectPolicy) None or Limit(n) (default: Limit(10))
retry(RetryPolicy) None or Limit(n) for idempotent requests
proxy(proxy) HTTP or SOCKS5 proxy
local_addr(addr) Bind outgoing connections to this address
dual_stack(bool) Interleave IPv6 / IPv4 candidates (Happy Eyeballs)
dns_cache_ttl(dur) Override default DNS TTL
dns_prefetch(hosts) Resolve hosts at build time
on_progress(fn) Default progress callback (upload + download)
progress_config(cfg) Throttle callback frequency (ProgressConfig::new(interval, min_bytes))
middleware(m) Append a Middleware to the chain
cookie_store() Enable automatic Set-Cookie / Cookie handling
cookie_jar(jar) Supply a pre-existing CookieJar
max_idle_per_host(n) Connection pool size per host (default: 8)
idle_timeout(dur) Evict pooled connections after this idle time (default: 90 s)
pool_config(cfg) Supply a complete PoolConfig
h2_keepalive(idle, ack) HTTP/2 PING keepalive timers
disable_h2_keepalive() Turn off HTTP/2 PING keepalive
compression_mode(mode) Auto (default) or Manual

ClientBuilder — protocol selection (applies to all requests from this client)

Method Description
prefer_http2() Try HTTP/2, fall back to HTTP/1.1
prefer_http3() Try HTTP/3 (QUIC), fall back to HTTP/1.1
http1_only() Force HTTP/1.1
http2_only() Force HTTP/2; error if unavailable
http3_only() Force HTTP/3 (QUIC); error if unavailable
prior_knowledge_h2c(bool) Skip HTTP/1.1 upgrade and speak h2c directly
protocol_policy(ProtocolPolicy) Set any ProtocolPolicy variant directly

ClientBuilder — TLS

Method Description
tls_config(cfg) Replace the entire TlsConfig
tls_backend(TlsBackend) Rustls (default) or NativeTls
root_store(RootStore) System, WebPki, PemFile(path), or Pem(str)
pin_certificate(domain, fingerprint) Pin a SHA-256 hex fingerprint for a domain
danger_accept_invalid_certs(bool) Skip certificate verification (tests only)
alpn_protocols(iter) Override ALPN list
disable_alpn() Send no ALPN extension

Requests

Every HTTP method on Client returns a RequestBuilder that implements IntoFuture.

// top-level convenience (uses a shared default client)
ugi::get("https://example.com").await?;

// per-client
client.get("/search").query([("q", "rust")]).await?;
client.post("/users").json(&payload)?.await?;
client.put("/items/1").text("body")?.await?;
client.delete("/items/1").await?;

RequestBuilder — body

Method Description
body(impl Into<Body>) Raw bytes body
body_stream(BodyStream) Streaming / chunked body
text(str) Body + content-type: text/plain; charset=utf-8
json(&T) Serialize to JSON + content-type: application/json
form(iter) URL-encoded form + content-type: application/x-www-form-urlencoded
multipart_form(iter<MultipartPart>) RFC 2046 multipart/form-data (text fields and file uploads)

RequestBuilder — common options (override the client default per-request)

Method Description
base_url(url) Override the client base URL for this request
header(name, value) / headers(iter) Append headers
cookie(name, value) / cookies(iter) Append cookies
query(iter) Append query pairs
bearer_auth(token) / basic_auth(user, pass) Auth header
timeout(dur) / connect_timeout(dur) / read_timeout(dur) / write_timeout(dur) Timeouts
redirect(RedirectPolicy) Override redirect policy
retry(RetryPolicy) Override retry policy
proxy(proxy) Override proxy
on_progress(fn) / progress_config(cfg) Progress reporting
rate_limit(bytes_per_sec) Throttle this response body
rate_limit_shared(RateLimiter) Share a rate budget with other requests
compression_mode(mode) Override compression negotiation
prefer_http2() / prefer_http3() Prefer a protocol version
http1_only() / http2_only() / http3_only() Force a protocol version
prior_knowledge_h2c(bool) h2c without upgrade
h2_keepalive(idle, ack) / disable_h2_keepalive() HTTP/2 PING
last_event_id(id) Set last-event-id header (SSE reconnect)
tls_config(cfg) / tls_backend(b) / root_store(s) TLS overrides
pin_certificate(domain, fp) / danger_accept_invalid_certs(bool) Cert pinning / skip verify
alpn_protocols(iter) / disable_alpn() ALPN overrides

Responses

let resp = client.get("/data").await?;

resp.status()          // StatusCode  (.as_u16(), .is_success(), …)
resp.version()         // Version::Http11 | Http2 | Http3
resp.url()             // &Url — final URL after any redirects
resp.headers()         // &HeaderMap
resp.headers_mut()     // &mut HeaderMap
resp.cookies()         // Vec<&str> of raw Set-Cookie values
resp.trailers()        // Option<&HeaderMap> — HTTP/2 trailer headers
resp.metrics()         // &Metrics  (timing, bytes transferred)
resp.error_for_status()  // Err if 4xx / 5xx

// consume the body (pick one)
resp.text().await?            // String
resp.bytes().await?           // Bytes
resp.json::<T>().await?       // serde-deserialized T
resp.bytes_stream()           // Pin<Box<dyn Stream<Item=Result<Bytes>>>>
resp.bytes_and_trailers().await?  // (Bytes, Option<HeaderMap>)
resp.sse()?                   // SseStream — use only for text/event-stream responses

Metrics — returned by resp.metrics():

Method Type Description
dns_duration() Option<Duration> Time spent on DNS resolution
connect_duration() Option<Duration> Time to establish the TCP connection
tls_duration() Option<Duration> Time for TLS handshake (None for plain HTTP)
request_write_duration() Option<Duration> Time to write the full request
ttfb() Option<Duration> Time to first byte of the response
download_duration() Option<Duration> Time to download the full response body
reused_connection() bool Whether the connection came from the pool
protocol() Option<Version> Negotiated protocol (Http11, Http2, Http3)

WebSocket

let mut ws = client.ws("wss://echo.example.com")
    .header("x-token", "secret")?
    .protocol("chat")          // request a sub-protocol
    .origin("https://app.example.com")
    .timeout(Duration::from_secs(10))
    .connect()
    .await?;

ws.send(WebSocketMessage::text("hello")).await?;
ws.send(WebSocketMessage::binary(bytes)).await?;
let msg = ws.recv().await?;
ws.close(None).await?;

WebSocketBuilder options:

Method Description
header(name, value) / headers(iter) Append handshake request headers
cookie(name, value) / cookies(iter) Append cookies to the handshake request
bearer_auth(token) / basic_auth(user, pass) Auth header
protocol(name) / protocols(iter) Sec-WebSocket-Protocol values
origin(value) Origin header
timeout(dur) / connect_timeout(dur) / read_timeout(dur) / write_timeout(dur) Timeouts
tls_config(TlsConfig) Full TLS override
tls_backend(TlsBackend) Switch between rustls / native-tls
danger_accept_invalid_certs(bool) Disable TLS certificate validation
alpn_protocols(iter) Custom ALPN protocol list
local_addr(SocketAddr) Bind the outgoing socket to a specific local address

Server-Sent Events

let resp = client.get("/events")
    .last_event_id("42")   // reconnect hint
    .await?;
let mut stream = resp.sse()?;
while let Some(ev) = stream.next().await {
    let ev = ev?;
    println!("{}: {} (id={:?})", ev.event, ev.data, ev.id);
}

SseEvent fields

Field Type Description
event String Event type (defaults to "message")
data String Event payload
id Option<String> Last-event-id set by the server
retry Option<Duration> Reconnection delay hint from the server

gRPC

// unary
let resp = client.grpc("https://api.example.com/pkg.Svc/Method")
    .metadata("x-request-id", "abc")?
    .message(&request)?
    .await?;
let reply: MyReply = resp.message().await?;

// server-streaming
let mut stream = client.grpc("https://…/pkg.Svc/ListItems")
    .message(&request)?
    .send_streaming()
    .await?;
while let Some(item) = stream.message::<MyItem>().await? {
    println!("{item:?}");
}

// bidirectional
let mut call = client.grpc("https://…/pkg.Svc/Chat")
    .open_duplex()
    .await?;
call.send(&ChatMessage { text: "hi".into() }).await?;
let reply: ChatMessage = call.recv().await?;

GrpcRequestBuilder options:

Method Description
metadata(name, value) Append a single ASCII request metadata header
metadata_map(iter) Append multiple ASCII metadata headers at once
metadata_bin(name, bytes) Append a binary metadata header (base64-encoded, name must end in -bin)
message(&T) Serialize a single Protobuf/JSON message as the request payload
messages(stream) Stream multiple messages (client-streaming / bidirectional)
message_bytes(bytes) Raw pre-encoded message bytes
messages_bytes(stream) Stream of raw pre-encoded message bytes
codec(GrpcCodec) GrpcCodec::Json (default) or GrpcCodec::Protobuf
compression(algo) Request-body compression algorithm (e.g. "gzip")
timeout(dur) / connect_timeout(dur) / read_timeout(dur) / write_timeout(dur) Timeouts
prefer_http2() / prefer_http3() / http2_only() / http3_only() Protocol preference
prior_knowledge_h2c(bool) Use HTTP/2 cleartext without upgrade handshake
tls_config(TlsConfig) Full TLS override
tls_backend(TlsBackend) Switch between rustls / native-tls
danger_accept_invalid_certs(bool) Disable TLS certificate validation
.await Execute as unary call; returns GrpcResponse
send_streaming() Execute; returns GrpcStreamingResponse for server-streaming calls
open_duplex() Open a bidirectional stream; returns GrpcDuplexCall

File download

use ugi::{Client, RateLimiter, download::HashAlgorithm};

// simple download
client.download("https://example.com/file.zip")
    .save(Path::new("file.zip"))
    .await?;

// parallel chunks + integrity check
client.download("https://example.com/large.iso")
    .chunks(8)                                       // parallel range requests
    .verify(HashAlgorithm::Sha256, "expected-hex")   // fail on mismatch
    .save(Path::new("large.iso"))
    .await?;

// hash only, no expected value
client.download("https://example.com/large.iso")
    .hash(HashAlgorithm::Md5)
    .save(Path::new("large.iso"))
    .await?;
// result.digest contains the computed hex string

// probe server without downloading
let caps = client.download("https://example.com/large.iso").probe().await?;
println!("size={:?} ranges={}", caps.content_length, caps.supports_range);

DownloadBuilder options

Method Description
chunks(n) Concurrent byte-range requests (default: 1; falls back if server rejects ranges)
hash(HashAlgorithm) Compute digest while downloading; available in DownloadResult.digest
verify(HashAlgorithm, expected_hex) Same as hash, but fails with ErrorKind::Decode on mismatch
rate_limit(bytes_per_sec) Throttle this download
rate_limit_shared(RateLimiter) Share a rate budget across concurrent downloads
probe() HEAD the URL; returns DownloadCapabilities without downloading
save(path) Execute the download; returns DownloadResult

DownloadResult fields:

Field Type Description
total_bytes u64 Total bytes written to disk
chunks_used usize Number of parallel byte-range chunks actually used
capabilities DownloadCapabilities Server capabilities probed before download (supports_range, content_length)
digest Option<DownloadDigest> Computed hash if hash() or verify() was called; contains algorithm and hex fields

Rate limiting

// per-request
client.get("https://example.com/big-file")
    .rate_limit(512 * 1024)   // 512 KB/s cap on this response body
    .await?;

// shared budget across concurrent requests or downloads
let limiter = RateLimiter::new(1024 * 1024); // 1 MB/s total
let r1 = client.get("https://example.com/a").rate_limit_shared(limiter.clone());
let r2 = client.get("https://example.com/b").rate_limit_shared(limiter.clone());
// drive r1 and r2 concurrently — they share the same token bucket

// adjust at runtime
limiter.set_rate(2 * 1024 * 1024).await;   // raise to 2 MB/s

Cloning a RateLimiter shares its underlying bucket — all clones draw from the same budget.

TLS and certificate pinning

// pin a SHA-256 fingerprint (colon-separated hex or base64, "sha256/" prefix optional)
let client = Client::builder()
    .pin_certificate("api.example.com", "sha256/AABBCC…")?
    .build()?;

// custom root CA from a PEM file
let client = Client::builder()
    .root_store(RootStore::PemFile("ca.pem".into()))
    .build()?;

// accept self-signed in tests
let client = Client::builder()
    .danger_accept_invalid_certs(true)
    .build()?;
// accept self-signed in tests
let client = Client::builder()
    .danger_accept_invalid_certs(true)
    .build()?;

Proxy

use ugi::Proxy;

// HTTP proxy
let client = Client::builder()
    .proxy(Proxy::http("127.0.0.1:8080"))
    .build()?;

// HTTP proxy with authentication
let client = Client::builder()
    .proxy(Proxy::http_with_auth("proxy.corp.example.com:3128", "user", "pass"))
    .build()?;

// SOCKS5 proxy
let client = Client::builder()
    .proxy(Proxy::socks5("127.0.0.1:1080"))
    .build()?;

// SOCKS5 with authentication
let client = Client::builder()
    .proxy(Proxy::socks5_with_auth("socks.corp.example.com:1080", "user", "pass"))
    .build()?;
Constructor Description
Proxy::http(addr) Plain HTTP CONNECT proxy
Proxy::http_with_auth(addr, user, pass) HTTP CONNECT proxy with Basic auth
Proxy::socks5(addr) SOCKS5 proxy (no auth)
Proxy::socks5_with_auth(addr, user, pass) SOCKS5 proxy with username/password

Middleware

use ugi::{Middleware, Next, Request, Response, Result};

struct LogMiddleware;

#[async_trait::async_trait]
impl Middleware for LogMiddleware {
    async fn handle(&self, req: Request, next: Next<'_>) -> Result<Response> {
        println!("--> {} {}", req.method().as_str(), req.url());
        let resp = next.run(req).await?;
        println!("<-- {}", resp.status());
        Ok(resp)
    }
}

let client = Client::builder()
    .middleware(LogMiddleware)
    .build()?;

Middlewares run in registration order. Next::run dispatches to the next middleware or the transport.

Progress reporting

use ugi::{Progress, ProgressConfig, ProgressPhase};
use std::time::Duration;

// throttle: fire at most once per 250 ms or 128 KB, whichever comes first
let config = ProgressConfig::new(Duration::from_millis(250), 128 * 1024);

let client = Client::builder()
    .on_progress(|p: Progress| {
        match p.phase() {
            ProgressPhase::Upload   => print!("up "),
            ProgressPhase::Download => print!("dn "),
        }
        println!("{}/{:?} done={}", p.transferred(), p.total(), p.is_done());
    }, config)
    .build()?;

// override per-request
client.get("/large").on_progress(|p| { /**/ }, ProgressConfig::default()).await?;

Type reference

ProtocolPolicy

Variant Description
Auto Use Alt-Svc hints; prefer HTTP/2 over HTTP/1.1; upgrade to HTTP/3 when advertised
PreferHttp2 Prefer HTTP/2, fall back to HTTP/1.1
Http2Only Require HTTP/2; error if not negotiated
PreferHttp3 Prefer HTTP/3 (QUIC), fall back to HTTP/1.1
Http3Only Require HTTP/3; error if not negotiated
Http1Only Force HTTP/1.1 always

RetryPolicy

Variant Description
None No retries (default)
Limit(n) Retry up to n times on transient errors for idempotent methods; exponential back-off (100 ms → 5 s cap)

CompressionMode

Variant Description
Auto Send Accept-Encoding and decompress automatically (default)
Manual Send no Accept-Encoding; body bytes are returned as-is
Disabled Explicitly opt out of compression negotiation

PoolConfig

Field Type Default Description
max_idle_per_host usize 8 Maximum idle connections per host
idle_timeout Option<Duration> 90 s Evict connections idle longer than this

RootStore

Variant Description
RootStore::system() Use the OS / system certificate store
RootStore::webpki() Use the built-in Mozilla WebPKI root bundle
RootStore::pem_file(path) Load PEM certificates from a file path
RootStore::pem(string) Load PEM certificates from a string

Examples

Example Description
get.rs Simple GET and JSON decode
json_post.rs POST JSON body
client_configured.rs Full client configuration
download.rs Parallel download with verification
ws_echo.rs WebSocket echo
grpc_unary_json.rs gRPC unary call
grpc_server_stream_json.rs gRPC server streaming
response_stream.rs SSE / streaming body
tls_pinning.rs Certificate pinning
http2_get.rs Force HTTP/2
http3_get.rs HTTP/3 (QUIC)
tokio_get.rs Tokio runtime
smol_get.rs smol runtime

Run any example:

cargo run --example get

License

MIT OR Apache-2.0