rightkit-http
Shared blocking HTTP client for Right Suite applications. It provides timeouts, bounded bodies, retry and backoff that honours Retry-After (never retrying unsafe methods), streaming responses, WHATWG Server-Sent Events decoding, redacted secrets, and tamper-evident wrapping of untrusted web text before it reaches a model.
Catalog capability: http-client. Published to crates.io at 0.2.5 (registry record); the source is 0.3.0, which INDEX.md flags as a mismatch.
What it owns
Client,ClientConfig,Request,Response,StreamResponse: timeouts, bounded bodies, redirect policy, streaming reads, andget_json.RetryPolicy: retry and backoff. Retries respectRetry-After. Non-idempotent methods are not retried after transport failures.Multipart:multipart/form-databodies with exact framing;Request::multipartsets the body andContent-Type.netpolicy/AddressPolicy: the outbound address policy (SSRF guard) that every request and redirect hop passes;ClientConfig::first_partypins a host set.CancelToken: cooperative cancellation for buffered calls and streams (HttpError::Cancelled).Request::deadline,Request::secret_headerandClientConfig::redact_values: a per-call budget, and secrets kept out of error text.AsyncClient(featureasync):send,send_any,stream,get_jsonwith identical timeout, retry/backoff, redirect, user-agent, proxy (off by default;ProxyModeopts in), address policy and size-limit behaviour.streamreturns aBodyStreamofByteschunks or anSseStream(.sse(),.sse_with_limits(max_line, max_event)), bounded to 1 MiB per line and 4 MiB per event by default.sse:SseDecoder(new,with_limits),SseReader,SseEvent. WHATWG-compliant Server-Sent Events.untrusted:wrap_untrusted_web_textfor external text, with boundary markers and injection-signal reporting;wrap_untrusted_web_text_coderightis the opt-in variant that emits CodeRight's<coderight-untrusted-context>envelope (differences:content_hashis the body SHA-256, closing-tag text in the body is escaped).secret: a redactedSecret, and theSecretStoretrait.MemorySecretStoreis provided for tests.src/secret.rssaysrightkit-secretssupplies the production implementation, butrightkit-secretshas noSecretStoreimplementation and does not depend on this crate.testing(feature): an in-process mock server that records exact requests.
Not in this release
File downloads (download_to_path) are not part of 0.3.0 and are planned for 0.3.1.
When to use it
- Use it for blocking HTTP calls from Right Suite apps where timeouts, retries and bounded bodies must be consistent.
- Use it for SSE streams and for wrapping web text before model input.
The default client is blocking (ureq with rustls). For async code enable the async feature: AsyncClient on reqwest with rustls, run inside a tokio runtime.
Cargo features
No feature is on by default (0.3.0). Name gzip and untrusted to get what 0.2.x built.
| Feature | Default | Pulls in | Enables |
|---|---|---|---|
gzip |
no | ureq/gzip |
Automatic Accept-Encoding: gzip and transparent decoding for buffered responses and stream readers. Matches ureq's default. |
untrusted |
no | rightkit-fs (SHA-256 digest) |
wrap_untrusted_web_text and WrapOptions (Cargo.toml:15-18, src/lib.rs:30-31). |
async |
no | reqwest (rustls, no native-tls), tokio (time, net), futures-util, bytes, rustls, rustls-platform-verifier, webpki-roots |
AsyncClient, AsyncStreamResponse, BodyStream, SseStream. Same ClientConfig, Request, Response, HttpError and policy as the blocking client. With gzip, also enables reqwest/gzip. |
testing |
no | none | The testing module: MockServer, MockResponse, MockBody, RecordedRequest, loopback_policy, loopback_config. |
Default features are empty since 0.3.0. Name what you use, e.g. features = ["gzip", "untrusted"]; with no features:
= { = "<exact published version from INDEX.md>", = false }
Usage
Client configuration, from the crate README. Every field below exists in ClientConfig:
use Duration;
use ;
let client = new;
Request shape, from tests/e2e.rs: Request::post(url).bearer(token).json(&value)? then client.send(&req)?.
Retries and unsafe methods
- Status-code retries (429, 5xx and the other
retry_statuses) apply to idempotent methods only. Since 0.3.0 POST and PATCH are not replayed by default, so a single-use OAuth code exchange is never sent twice;Request::retry_unsafe_statuses(true)opts one request in. Connect-phase transport failures are still retried for every method, because nothing reached the server. .retry(RetryPolicy::none())already disables every retry, including unsafe-status retries on POST and PATCH.RetryPolicy::none()hasmax_attempts = 1, so one request is made and no backoff sleep runs. Sub-second probes are therefore safe to call with it. The override and the policy are independent:retry_unsafe_statuses(true)withRetryPolicy::none()still makes exactly one request.Request::deadline(total)bounds all attempts and the retry sleeps between them. When the next sleep would pass the deadline, the last outcome is returned and no sleep runs. A retry sleep is also clamped to what the deadline has left after the failed response's body was drained, in both clients, so a stalled error body cannot push the call past its deadline by a full backoff.Request::timeoutandClientConfig::request_timeoutstay per-attempt, so a 300 to 600 s call budget with a 60 s attempt timeout isdeadline(300 s)plus the defaulttimeout.
Cancellation
Request::cancel(CancelToken) cancels the call. It is checked before each attempt, during retry sleeps (woken every 50 ms), and before each body read. A cancelled buffered call or retry returns HttpError::Cancelled. A stream returns an io::Error for which rightkit_http::is_cancelled_io is true. The blocking client cannot interrupt a socket wait for response headers or inside a body read: the first is bounded by response_timeout, the second only by recv_body_timeout or the stream or request timeout, when one is set. The async BodyStream (and SseStream on top of it) is different: when its request carries a CancelToken it wakes itself every 25 ms while pending, so cancel() ends a stream whose server has sent headers and then gone quiet, with no timeout configured, in at most about 25 ms (HttpError::Cancelled, then the stream ends).
Secrets in errors
- No error,
RetryEventorDebugoutput echoes a URL's query string or userinfo. URLs render asscheme://host[:port]/path?[redacted], andscheme://...text inside lower-layer messages is rewritten the same way. AStatusbody is the server's text and is only scrubbed of secret values. Request::secret_header(name, value)sends the header and records its value.Request::bearerdoes the same for the token. Recorded values never appear inHttpErrortext, inRetryEventURLs or reasons, in stream I/O errors, or inRequest'sDebugoutput.Request'sDebugprints the body length rather than its bytes.ClientConfig::redact_values(vec![...])scrubs configured strings in the same places. Each replaced value appears as[redacted].
Multipart
Multipart::new() (random boundary) or Multipart::with_boundary(b) (pinned). Add parts with text, file_bytes and file_path. Pass the form to Request::multipart, or call into_body and content_type yourself. The boundary must be 1 to 70 characters from ASCII letters, digits, -, _, . and +. into_body fails if the boundary occurs inside a part.
Timeouts
connect_timeoutandresponse_timeoutareOption<Duration>. The defaults areSome(10 s)for connect andSome(60 s)for response headers. UseNoneto disable a per-phase deadline. Disabling phase deadlines does not disable global deadlines.- Buffered calls use
request_timeout(60 s by default). Streams useRequest::timeoutwhen set, otherwisestream_timeout(Noneby default). recv_body_timeoutdefaults toNone. When set, it limits total body-receive time after headers arrive. Progress does not reset it.- Buffered body timeouts return
HttpError::Timeout. Stream readers keep the underlyingstd::io::Error. Body reads are never retried. - Callers that passed bare durations to
connect_timeoutorresponse_timeoutmust wrap them inSome(...). The change is recorded in CHANGELOG.md.
Migrating from a plain ureq agent
ClientConfig::legacy_ureq_parity(user_agent) sets the five options a plain ureq agent uses: ProxyMode::Environment, TrustRoots::Bundled, max_redirects 10, RetryPolicy::none() and the given user agent. Every other field keeps its default. It does not enable private-address resolution; opt into that separately with AddressPolicy::allow_private_resolution.
Error classes
HttpError::class() returns an ErrorClass, one coarse class per error, for mapping to an app's own error type or retry set.
HttpError |
ErrorClass |
|---|---|
Status 401, 403, 407 |
Auth |
Status 408 |
Timeout (HTTP 408 Request Timeout; was Client before Unreleased, see CHANGELOG) |
Status 429 |
RateLimit { retry_after } (the parsed Retry-After, or None) |
Status 500 to 599 |
Server |
Status, any other (other 4xx, an unfollowed 3xx) |
Client |
Timeout |
Timeout |
Transport |
Transport |
Cancelled |
Cancelled |
AddressDenied |
AddressDenied |
InvalidRequest, Json, Stream, BodyTooLarge |
Invalid |
Address policy
Every request, and every redirect hop, passes an outbound address policy before a socket opens. ClientConfig::address_policy defaults to public-web: the host is resolved once by this crate, every resolved address is classified, and the connection goes only to the addresses that were checked (no second lookup). One denied address denies the host. The client returns HttpError::AddressDenied { host, addr, class, reason }, which is never retried.
Denied by default: loopback, RFC 1918, link-local (including 169.254.169.254), CGNAT 100.64/10, multicast, reserved, unspecified and broadcast addresses, IPv6 fc00::/7 and fe80::/10, localhost and *.localhost, cloud metadata host names, and names that resolve to no address. IPv4-mapped, IPv4-compatible, NAT64 64:ff9b::/96 and 6to4 2002::/16 addresses take the class of the embedded IPv4 address. The classification mirrors rightkit-browser's policy.rs; netpolicy::classify_ip is the shared entry.
use ;
let policy = default
// Extra denials, with your own error text. Both also see the IPv4 inside an IPv6 form.
.deny_ranges
.deny_if
// An explicit escape for named hosts only: a local model server.
.allow_if;
let client = new;
deny_rangesanddeny_ifalways win overallow_if. Link-local and metadata destinations cannot be allowed by any hook.allow_if(hosts, pred)applies only to the exactly listed hosts (ASCII case-insensitive, one trailing dot ignored) and only to addresses for whichpredis true.Client::first_party(hosts)/ClientConfig::first_party(hosts)pin the allowed host set: exact host match, HTTPS only, on every redirect hop. The resolved addresses of those hosts are classified with the public-web classes too, so a listed name that resolves to a private address is refused. An on-premises server opts in per host withAddressPolicy::allow_private_resolution(host); link-local and metadata destinations,deny_rangesanddeny_ifstill apply. Use it for license checks and model downloads.AddressPolicy::with_resolverreplaces the system resolver (offline tests, pinned DNS).- Proxies: see Proxies. The default is no proxy, and environment variables are ignored. Through a proxy only literals and names can be checked.
- Tests and consumers that talk to
testing::MockServerusetesting::loopback_config()ortesting::loopback_policy(), which list the loopback hosts throughallow_if.
Proxies
ClientConfig::proxy takes a ProxyMode: None (default), Environment (ALL_PROXY, HTTPS_PROXY, HTTP_PROXY in that order, either case, plus NO_PROXY) or Explicit("http://10.0.0.5:3128"). Only http and https proxies are accepted. ProxyMode::None stays the default, and environment variables are ignored unless Environment is chosen. System uses the operating system's proxy for the current user: on Windows the static proxy and bypass list of the system proxy settings with nothing configured, and on other systems, it behaves as Environment.
Limits of System in this version: on a default Windows install, where "Automatically detect settings" is on, System fails closed with HttpError::SystemProxyUnsupported and sends nothing; a caller then chooses ProxyMode::None or an explicit proxy. There is no fallback inside the client. It follows the Windows settings exactly or not at all. When they hold anything this client cannot follow exactly, Client::try_new returns HttpError::SystemProxyUnsupported { element } (and every request of a client built with Client::new fails with it), element names the first such thing, and nothing is sent. No bypass entry is ever dropped while the proxy stays in use, and no scheme is sent to the other scheme's proxy. What is refused:
- Different routes for HTTP and HTTPS. The static proxy string is read per scheme as Windows reads it (
http=,https=, a barehost:portfor the rest). The same proxy for both is used; two proxies, or a proxy for one scheme only, are refused. - A bypass entry that is not a plain host. A host name, an address and
*.example.comare followed.<local>(names without a dot, set by "Don't use the proxy server for local addresses"), an entry limited to one scheme (https://host) and a wildcard elsewhere (10.*,*) are refused. - A proxy auto-config script, or automatic detection. Both choose a proxy per URL.
Many Windows machines also have <local> set. An app that offers System catches SystemProxyUnsupported and chooses ProxyMode::None or an explicit proxy itself.
Proxy mode is weaker than a direct connection by construction. The proxy resolves the destination name itself, so this crate cannot see or pin the address a proxied connection reaches. What it enforces is name- and literal-based:
- A proxy on a private address (
10.x,192.168.x) is accepted: the address policy governs the destination, and the endpoint is exempt from the class check only because the caller or the environment configured it, only for the real proxy connection (its host, its port, a call that goes through it).Client::proxy_diagnostic()reports the endpoint (never credentials). DNS-based private destinations cannot be verified through a proxy: only literals and the name list are checked. - The proxy endpoint is trusted: it is user or administrator configuration and is often on a private address. It gets no address-class denial. Proxy credentials in the URL never appear in
Debugoutput or errors. - IP-literal destinations (and
localhost) are classified like any address:169.254.169.254, RFC 1918, loopback and the rest are refused. - Hostnames are refused when they are
localhost,*.localhost,*.local,*.internalor single-label, unless anallow_ifrule lists the host. Cloud metadata names and the first-party host pin apply too.deny_rangesanddeny_ifapply to literals only, because a hostname's address is unknown. - Only literals are classified, and only
localhost,*.localhost,*.local,*.internaland single-label names are refused. A public-looking hostname that resolves to a private address at the proxy is not caught, anddeny_rangesanddeny_ifnever see it. Use a direct connection when that matters. - Every redirect hop is vetted the same way.
- Hosts that match
NO_PROXYconnect directly and get the full direct-connection check (resolve once, classify, connect to the checked address). - A first-party client uses the proxy too, unless
ClientConfig::proxy_bypass_first_party(true)sends it direct. - A proxy is trusted: it sees destinations and can forward anywhere.
Headers only
Client::send_headers(&req) returns ResponseHead { status, headers, final_url, hops, attempts } for any status without reading the body (3xx when redirects are off, 4xx, and 5xx after retries). The unread body is dropped, which closes that connection. Redirects, the address policy on every hop, retries and cancellation behave as for send_any. Request::headers_only() makes send and send_any skip the body in the same way. Client::stream_any returns any status with its headers and a streaming body; Client::stream still turns non-2xx into HttpError::Status.
Error phases
HttpError::Timeout { message, phase } and HttpError::Transport { message, connect_phase, phase } carry a Phase, also available as HttpError::phase():
Phase |
When |
|---|---|
Connect |
Name lookup, TCP connect, TLS handshake. connect_phase is true exactly here. |
Headers |
Sending the request and waiting for the response headers, including the request-wide budget. |
Body |
Reading the response body, including streams. |
Redirect |
Any failure on a redirect hop after the first request, and an exhausted redirect limit. |
TLS trust and https_only
ClientConfig::trustisTrustRoots::Platform(default) orTrustRoots::Bundled. From 0.3.0 the default is the operating system's trust store, so roots an enterprise or an inspection proxy installed on the machine are honoured, as curl and browsers honour them. Before 0.3.0 only Mozilla's bundled roots were trusted. Both clients name their verifier explicitly, so no other crate's Cargo features can change it: the blocking client sets ureq's TLS provider andRootCerts(PlatformVerifierorWebPki); the async client installs a complete rustlsClientConfigwithuse_preconfigured_tlsin both branches (rustls-platform-verifierforPlatform, thewebpki-rootsMozilla set and nothing else forBundled). reqwest stays 0.12.28 with featurerustls-tls; no second reqwest major is needed.Client::verifier_kind()andAsyncClient::verifier_kind()return"platform"or"bundled"; the async value reports the configuration that was installed.Bundledignores the machine's roots. There is no option that disables verification.- The async client advertises HTTP/1.1 only in ALPN. reqwest's
http2feature is not enabled here, so a server that selectedh2would otherwise panic hyper-util; the client cannot negotiate it, whatever other crates enable. ClientConfig::https_only(defaultfalse) refuses a non-httpsinitial URL and any redirect hop to a non-httpsURL withHttpError::HttpsRequired { host, scheme, redirect }(classInvalid), before anything is sent to that URL. It applies to both clients. It is separate from the address policy's HTTPS-to-HTTP redirect rule, which stays on; when a redirect tohttptrips both,HttpsRequiredis the error reported.
Defaults worth knowing
-
Routing is decided once per hop. Whether a request goes through the proxy is decided by one matcher (
ureq'sNO_PROXYrules, wildcards included) that also drives address vetting, and the async client keeps a direct and a proxied reqwest client and picks one per hop, with reqwest's own environment andNO_PROXYhandling switched off. A host the policy treats as direct is always vetted at the resolver; the same holds after redirects. -
DebugofResponseandResponseHeadhides every recorded secret (redact_values,secret_headervalues, URL secrets) as well as credential-named headers. -
Redirects: none by default (
max_redirects = 0). A 3xx comes back as the response. SetClientConfig::max_redirects(andmax_redirects_will_error) to follow them; each hop passes the address policy and strips credentials on a new origin. -
Retries: idempotent methods only. POST and PATCH are never replayed after a 429/5xx unless the request sets
retry_unsafe_statuses(true). A connect-phase failure (nothing reached the server) is still retried for every method. -
Streams and quiet SSE.
ClientConfig::response_timeout(default 60 s) is the time to the response headers (first byte), not an idle timeout. Once headers arrive, a stream body has no idle cut unlessrecv_body_timeout(a total body budget, default none) orstream_timeout/Request::timeout(total lifetime, default none) is set. A server that sends headers and then stays quiet is therefore not cut; one that takes over 60 s to send headers is, so raise or clearresponse_timeout(None) for such servers. There is no per-read idle timeout; useRequest::cancelordeadlineto bound a stalled stream. -
Text decoding is lossy UTF-8.
Response::text()and error bodies decode as UTF-8 and replace invalid bytes; theContent-Typecharset is not honoured. For another charset, takeResponse::bodybytes and decode them yourself.
Hard blocks and spellings
- Cloud metadata cannot be allowed by any hook. Link-local (which holds
169.254.169.254),fd00:ec2::/32(AWS IPv6 metadata and DNS),100.100.100.200(Alibaba),168.63.129.16(Azure) and192.0.0.192, plus IPv6 forms that embed them, are refused whateverallow_if,allow_private_resolutionor the proxy mode say. Cloud metadata host names are refused too. - Legacy IPv4 spellings such as
127.1,0177.0.0.1,0x7f.0.0.1and2130706433are read the wayinet_atonreads them before any check, in both clients, through proxies and on redirects. - IPv6 forms that embed IPv4 take the embedded class: mapped, compatible, NAT64
64:ff9b::/96, 6to4, and the local-use NAT64 space64:ff9b:1::/48in each RFC 6052 layout. - Non-canonical IPv4 spellings are refused by default (
AddressPolicy::allow_legacy_ipv4()opts out): parsers disagree about0177.0.0.1, so normalising it would be an SSRF bypass.parse_host_ip(host)returnsOption<ParsedHostIp>(the address,kindandis_canonical) for callers guarding other transports;parse_legacy_ipv4reads theinet_atonforms (one to four parts, decimal/octal/hex, the last part fills the remaining bytes). - HTTPS to HTTP redirects are refused by the address policy.
AddressPolicy::allow_insecure_redirects()opts in. - Logging: the vendored
ureq3.4.0 documentation describes itsTRACElevel as wire-level debug output that is "NOT REDACTED" (ureq3.4.0,src/lib.rs:449). This crate cannot intercept it. Do not enable TRACE for a process that carries secrets in URLs.
Vetting for other transports
AddressPolicy::resolve_vetted(host, port) -> Result<Vec<SocketAddr>, HttpError> resolves a host once and applies exactly the rules the clients use: the address classes, deny_ranges, deny_if, allow_if, the first-party pin and its private-resolution opt-outs, metadata names, and the denial of names with no address. It returns the same HttpError::AddressDenied. A WebSocket, raw TCP or gRPC transport calls it and must connect to the returned addresses, not look the name up again. A transport that cannot pin its connection leaves a DNS-rebinding window between the check and its own lookup. The host is treated as reached over HTTPS; requiring TLS under a first-party policy is the caller's job.
Redirects
max_redirectsdefaults to 0, so redirects are not followed. With 0, a 3xx response is always returned, whatever the error policy.- The crate follows redirects itself, one hop at a time, so the address policy and header stripping run on every hop. Rules match ureq: 301/302/303 and other 3xx (not 304) become
GETunless the method isGETorHEAD; 307/308 are not followed forPOST,PUT,PATCHandDELETE(the 3xx comes back); a 3xx withoutLocationcomes back. - On a cross-origin hop (scheme, host or port differs)
Authorization,Cookie, headers named like credentials (authorization,authentication,cookie,token,secret,api-key,apikey,password) and any header holding a recorded secret value are dropped. A same-origin hop keeps them. max_redirects_will_errordefaults totrue, matching ureq. When a nonzero limit is exhausted, the call returnsHttpError::Transport { connect_phase: false, .. }. That error is eligible for idempotent retries under the retry policy.- Set it to
falseto receive the final 3xx throughsend_any.
Platform support
Pure Rust over ureq with rustls, with platform-specific code in src/system_proxy.rs (Windows system proxy settings). Certificate and TLS behaviour follows rustls; the default roots are the operating system's (TrustRoots::Platform).
Tests
tests/e2e.rs and, with --features async, tests/async_e2e.rs: a real local TCP server. Assertions use exact requests seen on the wire and exact bytes received. tests/second_read.rs (and tests/second_read_async.rs with --features async) pin the retry-sleep clamp. tests/request_policy.rs covers per-request overrides, cancellation, redaction and the deadline. tests/address_policy.rs (and tests/async_policy.rs with --features async) cover the address policy: resolver denial per class, embedded IPv4 forms, redirects, cross-origin stripping, first-party mode, deny_ranges, deny_if and URL redaction. tests/send_headers.rs, tests/proxy.rs and tests/phases.rs cover headers-only calls, proxies and error phases. tests/classify_parity.rs cross-checks the classification against rightkit-browser's source.
Version and changes
Published version: see INDEX.md. Version history: CHANGELOG.md.