# ugi
A runtime-agnostic Rust HTTP client with full protocol coverage and a uniform builder API.
```rust
let client = ugi::Client::builder().build()?;
let resp = client.get("https://api.example.com/users/1").await?;
let user: User = resp.json().await?;
```
## Features
| **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.
| **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`):
| 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`](docs/performance.md).
## Quick start
Add to `Cargo.toml`:
```toml
[dependencies]
ugi = "0.2.1"
```
The default feature set is full-featured (`rustls` + `h2` + brotli + zstd). Opt out of heavy codecs or switch TLS back-ends as needed:
```toml
# System TLS instead of rustls, no brotli/zstd
ugi = { version = "0.2.1", default-features = false, features = ["native-tls", "h2"] }
```
### Cargo features
| `rustls` | yes | TLS via `async-tls` / rustls (no OpenSSL dependency) |
| `native-tls` | no | TLS via OS certificate store (mutually exclusive with `rustls`) |
| `btls-backend` | no | High-fidelity BoringSSL-family TLS backend without preset emulation |
| `boring-backend` | no | Compatibility alias for `btls-backend` |
| `emulation` | no | Browser/network emulation presets, `btls` TLS fingerprinting, H2 fingerprinting, and strict no-downgrade behavior |
| `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.
### Browser/network emulation
Enable the feature explicitly:
```toml
ugi = { version = "0.2.1", features = ["emulation"] }
```
Simple preset path:
```rust
let client = ugi::Client::builder()
.emulation(ugi::Emulation::Chrome136)
.build()?;
```
Custom profile path:
```rust
let profile = ugi::EmulationProfile::builder()
.default_header("user-agent", "CustomAgent/1.0")?
.build();
let client = ugi::Client::builder()
.emulation(profile)
.build()?;
```
`.emulation(..)` is the preferred public entrypoint for both presets and custom
profiles. Compatibility aliases exist, but the library is intentionally
centered on a single emulation switch.
Example binaries:
```bash
cargo run --example emulation_preset --features emulation
cargo run --example emulation_custom --features emulation
```
### GraphQL with other crates
`ugi` stays at the transport layer for GraphQL use cases. The intended pattern
is to build the GraphQL envelope with plain `serde` types or a dedicated query
crate, then send it with `RequestBuilder::json(...)`.
Repository examples:
```bash
cargo run --example graphql_json
cargo run --example graphql_graphql_client
```
- `graphql_json` shows the minimal plain-JSON envelope pattern.
- `graphql_graphql_client` shows `graphql_client::QueryBody` over `ugi`
transport without changing `ugi`'s public API.
### Manual online smoke tests for emulation
The repository includes an ignored online smoke suite for the current browser
presets. It defaults to [tls.peet.ws](https://tls.peet.ws/api/all), but can be
pointed at a different probe endpoint with `UGI_EMULATION_SMOKE_URL`.
```bash
UGI_RUN_ONLINE_SMOKE=1 cargo test --test emulation_online_smoke --features emulation -- --ignored
```
This suite is intentionally opt-in:
- the tests are `#[ignore]`
- `UGI_RUN_ONLINE_SMOKE=1` must be set explicitly
- the target URL can be overridden for nightly or internal probe infrastructure
## Runtime compatibility
```rust
// 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
```rust
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**
| `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` |
| `emulation(profile)` | Apply a preset or custom emulation profile (`emulation` feature) |
**`ClientBuilder` — protocol selection** (applies to all requests from this client)
| `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**
| `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`.
```rust
// 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**
| `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)
| `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 |
| `emulation(profile)` | Replace the effective emulation profile for this request (`emulation` feature) |
### Responses
```rust
let resp = client.get("/data").await?;
resp.status() // StatusCode (.as_u16(), .is_success(), …)
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()`:
| `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
```rust
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:**
| `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
```rust
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
| `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
```rust
// 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:**
| `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
```rust
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**
| `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:
| `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
```rust
// 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
```rust
// 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()?;
```
```rust
// accept self-signed in tests
let client = Client::builder()
.danger_accept_invalid_certs(true)
.build()?;
```
### Proxy
```rust
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()?;
```
| `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
```rust
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
```rust
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`**
| `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`**
| `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`**
| `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`**
| `max_idle_per_host` | `usize` | 8 | Maximum idle connections per host |
| `idle_timeout` | `Option<Duration>` | 90 s | Evict connections idle longer than this |
**`RootStore`**
| `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
| [`get.rs`](examples/get.rs) | Simple GET and JSON decode |
| [`json_post.rs`](examples/json_post.rs) | POST JSON body |
| [`client_configured.rs`](examples/client_configured.rs) | Full client configuration |
| [`download.rs`](examples/download.rs) | Parallel download with verification |
| [`ws_echo.rs`](examples/ws_echo.rs) | WebSocket echo |
| [`grpc_unary_json.rs`](examples/grpc_unary_json.rs) | gRPC unary call |
| [`grpc_server_stream_json.rs`](examples/grpc_server_stream_json.rs) | gRPC server streaming |
| [`response_stream.rs`](examples/response_stream.rs) | SSE / streaming body |
| [`tls_pinning.rs`](examples/tls_pinning.rs) | Certificate pinning |
| [`http2_get.rs`](examples/http2_get.rs) | Force HTTP/2 |
| [`http3_get.rs`](examples/http3_get.rs) | HTTP/3 (QUIC) |
| [`tokio_get.rs`](examples/tokio_get.rs) | Tokio runtime |
| [`smol_get.rs`](examples/smol_get.rs) | smol runtime |
Run any example:
```sh
cargo run --example get
```
## License
MIT OR Apache-2.0