openwire
OpenWire is an OkHttp-inspired async HTTP client for Rust.
It uses hyper for HTTP protocol state, but owns the client-side semantics
around request policy, route planning, connection pooling, fast fallback, and
protocol binding. The default executor/timer and TLS integrations are Tokio and
Rustls.
It is aimed at cases where a plain protocol client is not enough and the networking layer needs clear policy behavior, reusable transport building blocks, and stable observability hooks.
What It Provides
Client,ClientBuilder, and single-executionCalloverhttp::Request<RequestBody>- OkHttp-style
Callhandles for cancellation, execution state, replayable cloning, and executor-backed queued calls - request-scoped timeout, retry, and redirect overrides through
Call - application and network interceptors
- built-in
LoggerInterceptorwithLogLevel::{Basic, Headers, Body} - event listeners and stable request / connection observability
- retries, redirects, cookies, and origin / proxy authentication follow-ups with structured HTTP authentication challenge parsing
- request validation rejects HTTP URI authorities that include userinfo before bridge normalization or network I/O
- transparent response decompression for
br,gzip,deflate, andzstdthrough the defaultcompressionfeature - HTTP forward proxy, HTTPS CONNECT proxy, and SOCKS5 proxy support,
including
socks5://user:pass@host:portcredentials and proxy-endpoint fast fallback - dynamic per-request proxy selection via
ProxySelector, including ordered proxy candidate fallback andDIRECT, withProxyRulesas the built-in rule-based implementation - custom DNS, TCP, TLS, executor, and timer hooks
- an owned connection core with route planning, pooling, and direct HTTP/1.1 / HTTP/2 protocol binding
RequestBody::absent()for typical no-body requests andRequestBody::explicit_empty()when zero-length framing must be explicit- optional JSON helpers behind the
jsonfeature - optional WebSocket (RFC 6455) client behind the
websocketfeature, with a pluggableWebSocketEnginetrait and a built-in native codec openwire-cacheas a separate application-layer cache crate with an RFC 9111-aligned freshness subset
Workspace
crates/openwire: public client API, policy layer, transport integrationcrates/openwire-cache: cache interceptor and in-memory cache storecrates/openwire-core: shared body, error, event, executor/timer, transport, and policy traitscrates/openwire-fastwebsockets: optionalfastwebsocketsWebSocket engine adaptercrates/openwire-tokio: Tokio executor, timer, I/O, DNS, and TCP adapterscrates/openwire-rustls: default Rustls TLS connectorcrates/openwire-tungstenite: optionaltokio-tungsteniteWebSocket engine adaptercrates/openwire-test: local test support
Tokio-specific adapters are imported from openwire-tokio directly; openwire
keeps the client API and higher-level policy / planning surfaces.
Installation
The first planned crates.io release is 0.1.0:
[]
= "0.1.0"
Optional companion crates are published with the same workspace version, for
example openwire-cache = "0.1.0" or openwire-tungstenite = "0.1.0".
Release and versioning steps are documented in docs/release-process.md.
Quick Start
use Request;
use ;
async
Request-scoped overrides stay on the canonical execution path:
use Duration;
let response = client
.new_call
.call_timeout
.connect_timeout
.follow_redirects
.execute
.await?;
These per-request retry and redirect overrides target the built-in scalar policy
knobs. Custom RetryPolicy and RedirectPolicy objects remain client-scoped.
Calls can also be controlled after they have been moved into async execution.
Call::handle() returns a cloneable cancellation and state handle, and
Call::try_clone() creates a fresh unexecuted call when the request body is
replayable:
let call = client
.new_call
.call_timeout;
let handle = call.handle;
let task = spawn;
handle.cancel;
let error = task.await?.expect_err;
assert_eq!;
For OkHttp-style asynchronous dispatch through the client's configured executor, queue the call and await the returned handle:
let queued = client.new_call.enqueue?;
let response = queued.await_response.await?;
Dropping QueuedCall does not request cancellation; call cancel() on the
queued call or its CallHandle when the in-flight work should stop.
Authentication Challenges
Authenticator receives an AuthContext for origin 401 Unauthorized,
forward-proxy 407 Proxy Authentication Required, and HTTPS CONNECT proxy
authentication challenges. AuthContext::challenges() parses the applicable
RFC 9110 / RFC 7235 authentication header into AuthChallenge values:
- origin authentication reads
WWW-Authenticate - proxy authentication reads
Proxy-Authenticate - each challenge exposes its auth scheme, optional token68 value, parameters,
and convenience access to
realm
OpenWire does not choose credentials by itself. The caller's authenticator
selects a supported challenge and returns a replayable follow-up request with
Authorization or Proxy-Authorization as appropriate.
Proxy authentication follow-ups are only attempted for responses produced by a
selected proxy route. If an origin server on a direct route returns 407,
OpenWire returns that response to the caller instead of treating it as a proxy
challenge.
HTTP Logging
OpenWire includes an OkHttp-style LoggerInterceptor that can be attached as an
application interceptor for logical-call logging or as a network interceptor for
post-normalization, per-attempt wire logging:
use Request;
use ;
let client = builder
.application_interceptor
.build?;
let request = builder
.method
.uri
.header
.header
.body?;
let response = client.execute.await?;
println!;
LogLevel::Body pretty-prints JSON with serde_json::to_writer_pretty, redacts
Authorization, Proxy-Authorization, Cookie, and Set-Cookie by default,
and only buffers bodies when they are replayable and bounded. Streaming request
bodies, chunked responses, SSE, upgraded protocols, and oversized bodies are
logged as omitted placeholders instead of being fully drained into memory.
The WebSocket path still bypasses the interceptor chain today, so
LoggerInterceptor covers HTTP calls made through Client::execute(...) /
Call::execute() rather than Client::new_websocket(...).
Proxy routing is configured through a selector so the active proxy can change at execution time. A selector can return multiple candidates for one request; the transport tries them in order within the same logical attempt:
use ;
;
let client = builder
.proxy_selector
.build?;
ProxyRules remains available when a simple ordered rule list is enough.
Once a proxied attempt succeeds, later auth and redirect follow-ups in the same
logical call prefer that proxy first so proxy-authorization state stays bound
to the proxy that actually handled the request.
Origin and proxy authenticators receive an AuthContext with the logical call
counters accumulated before the authentication decision: total attempt number,
retry count, redirect count, and completed auth follow-up count. Forward-proxy
HTTP 407 follow-ups require a selected proxy route; direct-origin 407
responses are returned unchanged. HTTPS CONNECT proxy challenges are raised
while the tunnel is being established, but their proxy authenticator context
uses those same logical counters; repeated CONNECT 407 tunnel retries add their
tunnel-local proxy auth count to the logical auth count before calling the
authenticator again. ClientBuilder::max_auth_attempts is a per-logical-call
budget, so CONNECT proxy authentication also stops once the logical auth count
plus completed CONNECT-local retries reaches that limit.
Transparent Compression
With the default compression feature enabled, the bridge injects
Accept-Encoding: br, gzip, deflate, zstd for normal HTTP requests that do not
already set Accept-Encoding and are not range requests. Responses using those
encodings are decoded as a stream before they reach application interceptors or
callers, and the decoded response omits the wire Content-Encoding and
compressed Content-Length headers.
If a caller sets Accept-Encoding explicitly, OpenWire leaves the response
body and headers untouched so the caller owns the wire encoding semantics.
Application Cache
openwire-cache provides an application interceptor for private, in-process
response caching. It currently caches replayable GET responses with explicit
or conservative heuristic freshness metadata and honors the core RFC 9111
controls needed to avoid unsafe reuse:
- request
Cache-Control: no-cache,no-store,max-age=0,max-stale,min-fresh, andonly-if-cached - request
Pragma: no-cacheas an HTTP/1.0 compatibility signal whenCache-Controlis absent - response
Cache-Control: max-age,must-revalidate,no-cache, andno-store Expiresfreshness whenmax-ageis absentAgereducing remainingmax-agefreshnessDateapparent age and Last-Modified heuristic freshness when explicit freshness is absent- multiple stored variants for one URI through
Varymatching against the original request headers, withVary: *treated as not reusable - stale stored responses with
ETagorLast-Modifiedvalidators are revalidated with conditional GET requests, and304 Not Modifiedresponses refresh stored metadata before returning the cached body as200 OK - stale stored responses are served only when the request explicitly permits
them with
max-staleand the cached response does not require validation - non-error
2xx/3xxunsafe-method responses invalidate stored responses for the target URI, plus same-hostLocationandContent-Locationresponse URIs when present - cached hits generate a current
Ageheader
The cache intentionally remains conservative: it only stores 200 OK GET
responses, skips responses with Set-Cookie, skips authenticated requests, and
does not yet implement stale-if-error or background stale revalidation.
Default Transport Settings
Client::builder() currently defaults to:
- pooled idle connection eviction after 5 minutes
- at most 5 idle pooled connections per address
- at most 64 in-flight requests across the client
- at most 5 in-flight requests per address
These request and pool limits are bounded by address, not only origin host. If a
caller needs the previous unbounded request-admission or idle-pool behavior, set
the corresponding knobs explicitly, for example with usize::MAX.
Current Status
Today the project includes:
- request execution through
Client::execute(...)andCall::execute() - cancellation, execution-state handles, replayable
Call::try_clone(), and queued calls throughCall::enqueue() - application and network interceptors
- retry, redirect, cookie, and authenticator follow-up handling
- HTTP forward proxy, HTTPS CONNECT proxy, and SOCKS5 proxy support
- owned HTTP/1.1 and HTTP/2 bindings via
hyper::client::conn - connection pooling, fast fallback, and route planning
- optional RFC 9111-oriented cache integration in
openwire-cache - an opt-in live-network smoke suite outside the required CI path
Development
Optional live-network smoke suite:
This suite is opt-in, hits public internet endpoints, and is not part of the required CI gate.
The repository also provides a separate GitHub Actions workflow at
.github/workflows/live-network.yml for manual dispatches and weekly scheduled
runs without affecting the required CI path.
Deferred public-origin follow-ons are intentionally kept out of this baseline when they require external credentials, temporary remote resources, untrusted public proxies, or timing-sensitive assertions that public networks cannot make credible. Those follow-ons are tracked in docs/live-network-follow-ups.md.
Architecture
Detailed execution flow, transport layering, and extension boundaries are in docs/ARCHITECTURE.md.
Error-handling review, current gaps, and the long-term failure-model roadmap are tracked in docs/error-handling-roadmap.md.