-
Notifications
You must be signed in to change notification settings - Fork 3
kithara net
Documentation reviewed from source revision 19ca073f2. This records the documented contract at that revision; it is not a new runtime validation. API and usage · All crates.
Cargo features select the transport. The choice is invisible above the Net trait — HttpClient, NetOptions, and
NetError are backend-agnostic.
| Feature | Client | TLS | Targets | Notes |
|---|---|---|---|---|
client-reqwest (default) |
reqwest |
tls-rustls (default) / tls-native
|
native + wasm | Pure-Rust, portable. The only backend on wasm32. |
client-wreq |
wreq |
BoringSSL (fixed) |
native only | Browser TLS/HTTP2 emulation (ImpersonatePreset) to defeat anti-bot WAF JA3 fingerprinting. |
client-apple |
NSURLSession |
Apple platform trust | macOS + iOS only | Foundation bindings come from kithara-apple/foundation. |
-
At least one usable backend.
backend/mod.rsraisescompile_error!when no backend feature is selected, or when wasm32 is built withoutclient-reqwest. There is no silent default-to-reqwest. -
Features are additive; selection is cfg-priority, not mutual exclusion. Native
client-wreqshadowsclient-reqwest; on macOS/iOSclient-appleshadows the whole reqwest/wreq seam (src/client.rsis not even compiled). -
The TLS axis applies only to
client-reqwest. No-op underclient-wreq(always BoringSSL),client-apple(platform trust), and on wasm (browser owns TLS, sowreqis gated out of wasm builds entirely). - Reqwest is the default because feature unification makes "disable a transitive default" impossible in practice while
"add a forwarded feature" composes, so the backend pulling a C toolchain (
wreq→ BoringSSL) must be opt-in. - Minor features:
mockexposescrate::mock::NetMock(native only);perfattacheshotpath::measureto theRawHttpNetmethods;probeis a pass-through flag with no code in this crate.
client.rs owns the shared RawHttp / HttpClient logic against the uniform backend seam and contains no backend
selection. When client-apple is not active, the seam re-exports Client, RequestBuilder, Response, StatusCode,
BackendError, build_client(&NetOptions, &ConnectionMetrics), head_request(&Client, &Url), and
post_request(&Client, &Url, Bytes). ClientBuilder, apply_compression, and the connector-metrics layer stay under
backend/native/; shared HTTP code must not import backend crates directly. The Apple adapter (backend/apple/) bypasses
that seam — Foundation streams through a delegate, not a Rust response type — and exports only HttpClient = AppleNet.
On wasm, head is a one-byte ranged GET.
No native client sets a client-level read_timeout or wall-clock timer: the idle timer is owned by resumable_body,
whose sleep routes through kithara_platform::time and collapses under flash. A second wall-clock timer would
double-own the stall and break simulation determinism.
NetOptions::compression owns content-coding negotiation; caller-supplied Accept-Encoding headers are dropped, never
merged. Whole-body get_bytes / post_bytes advertise exactly the configured set (AcceptEncodingPolicy::Configured).
stream, get_range, and head advertise identity, because byte offsets must stay in the same representation as
cached bytes and Content-Length. A 2xx response still carrying a non-identity Content-Encoding under the identity
policy is rejected as NetError::Decode before any body byte reaches a downloader or asset store.
The request-level header is authoritative even under wreq emulation: disabling a decoder with ClientBuilder::no_* is not
enough, because an emulation preset may already have installed its own header. Apple preserves Foundation's auto-decode of
whole-body responses by dropping the now-stale content-encoding and encoded content-length response headers.
Every streaming response validates its status and range headers before exposing the body. A 206 Partial Content
response must carry a parseable content-range whose start matches the request, whose inclusive end does not exceed the
requested bound, and whose numeric total exceeds that end. Unknown * totals are rejected because neither the network
stream nor its caller can prove representation completion. A partial response must also carry a parseable
content-length exactly matching the declared range span, so a chunked or overlong body cannot escape its interval. A 200 OK may
represent a server that ignored Range, but it must not carry content-range; other successful statuses are invalid for
a range request. Any mismatch is a fatal NetError::Decode with the request URL and range, so a resumed body cannot append
bytes from a different interval or commit a truncated asset. Streaming calls that did not request a range reject
unsolicited 206 responses. The same validation owns initial requests and resumable re-fetches on every backend.
The resumable stream pins the first known representation total and rejects a re-fetch whose known total conflicts, even
when the resumed byte interval itself is valid. It also preserves the first response envelope: recovery may fill missing
bytes in that response, but cannot expose bytes beyond its declared length.
-
No unsafe. The crate root is
#![forbid(unsafe_code)]. All Objective-C glue (delegate class, blocks, selectors) lives behind the safekithara_apple::foundationfacade;kithara-netdeclares noobjc2/block2dependency. New Foundation bridging belongs inkithara-apple. -
Sessions are shared process-wide, not per client.
AppleSessionresolves through a process-global registry keyed bySharedSessionKey { is_insecure, max_connections_per_host }, so everyHttpClientwith matching options reuses one ephemeralNSURLSessionand one Foundation connection pool. Do not split data and streaming requests onto separate sessions without a new explicit pooling contract. -
Pool knob mapping.
pool_max_idle_per_hostis applied as Foundation'sHTTPMaximumConnectionsPerHost(a cap on simultaneous persistent connections per host — the closest documented control, not the same semantics) and only when it converts to a positiveNSInteger; otherwise Foundation's default stands. Do not substituteNSInteger::MAXor another sentinel. -
Timeout ownership matches the other backends. Header/data establishment is wrapped in the Rust-side
inactivity_timeout; body inactivity stays withresumable_body. No Foundation timeout is configured, so Foundation cannot race the flash-aware idle timers. -
Cancellation.
wait_for_data/wait_for_stream_headcancel the task on token fire, RAII guards cancel a task dropped mid-startup, andAppleBodyStreamregisters a cancel waker so a parked poll wakes promptly. Dropping a body stream before EOF cancels the task. Cancellation observed aftercontent-lengthbytes already arrived ends the stream as a clean EOF, notNetError::Cancelled. -
Body backpressure. Delegate callbacks push chunks into
AppleBodyQueue; atbody_queue_capacitychunks the task is suspended and resumes once the queue drains tobody_queue_resume_at. Capacity0disables suspension. -
is_insecureis honoured in the delegate's authentication-challenge handler (UseServerTrustCredential), not through a client builder flag.
HttpClient::connection_count() is the opened-connection counter behind shared-pool regression tests: native counts
successful connector-layer opens, Apple counts NSURLSessionTaskMetrics.transactionMetrics entries with
isReusedConnection == false, wasm always reports zero. Server-side TCP instrumentation belongs in test-server support,
never in production code.
HttpClient (or AppleNet) is RetryNet<Raw*, DefaultRetryPolicy> over the raw one-shot client; Raw* is never
constructed by callers. HttpClient::new(options, pools, cancel) panics if the backend builder fails, and cancel MUST come
from the consumer crate's cancel tree (master_cancel.child() at App / Queue / FFI player) — orphan tokens are
forbidden in production code. with_observer rebuilds the retry layer around the same inner client and
ConnectionMetrics, so swapping observers never reopens the pool.
Two independent budgets, each sized by RetryPolicy::max_retries:
-
RetryNetretries whole calls. A transient error surfacing after a non-zero budget was spent is promoted to a terminalNetError::RetryExhausted(Fatal) so downstream treats it as a give-up, not a retry signal. Undermax_retries == 0the decorator is a deliberate pass-through and the raw transient error propagates unchanged. -
resumable_bodyheals one established body: on a stall (no chunk withininactivity_timeout), a transient chunk error, or a clean EOF before the promisedcontent-length, it re-fetchesbytes=base_start+consumed-end. A206resume yieldsskip = 0; a200(server ignoredRange) yieldsskip = base_start + consumed, so the consumer still sees one continuous, non-duplicated stream. Fatal causes and cancellation end the stream at once.
Every streaming fetch (stream, get_range) is wrapped by resumable_body before reaching a consumer. get_bytes /
post_bytes do not ride the resilient stream, so their body collection is separately bounded by inactivity_timeout per
chunk.
TimeoutNet<N> wraps every method in kithara_platform::time::timeout and is public. The retry wrapper is reachable only
through NetExt::with_retry; the type itself is not nameable outside the crate.
post_bytes flows through the same retry layer as the read methods, so it is at-least-once: a transient failure after
the server already accepted the write can re-send it. Callers issuing non-idempotent requests must carry their own
idempotency key. The caller owns Content-Type and auth via headers — the layer stays body-agnostic.
NetOptions::inactivity_timeout (default 30s) is the only request-level limit, applied to all five methods. It bounds
each read gap — establish (connect + response headers), each body chunk, and the diagnostic error-body read — and never
the total request lifetime, so a slow stream that keeps delivering chunks is not aborted. There is no total-lifetime cap
in NetOptions; callers wanting one compose TimeoutNet. The 30s default absorbs realistic mobile stalls (TCP
retransmits, captive-portal warm-up, TTFB spikes) — the player contract is "wait for the segment regardless of connection
speed", and a 10s cap raced real fixtures.
NetOptions is a bon builder with defaults: compression all four codings, inactivity_timeout 30s, impersonate
Safari, retry_policy (3 retries / 100ms base / 5s max, exponential ×2), is_insecure false, body_queue_capacity 32,
body_queue_resume_at 16, pool_max_idle_per_host 8, and pool_idle_timeout 5s. The application-owned
PoolRegion<S> is an explicit HttpClient constructor dependency and stays out of network policy. pool_idle_timeout reaches the two
native client builders only; the Apple backend has no Foundation equivalent for it. Native clients additionally enable
the cookie store.
HttpClient::with_observer rebuilds NetOptions by struct update, so a new option is carried over by construction —
a field-by-field rebuild would silently reset any knob it forgot.
NetOptionsPatch and RetryPolicyPatch are what a configuration document may say about those options. They are
Deserialize only: a patch reaching this crate has already had its references resolved, so nothing serializes one back
out. Every field is optional and a patch writes only the fields it names, leaving the builder's value standing
everywhere else; retry_policy is itself a patch, so a document naming one retry field keeps the other two.
observer is not in the patch at all — an owner is wiring, not configuration. Durations are humantime strings
(250ms, 2s), enums are snake_case names, and an unknown key is refused by name rather than ignored.
Compression reads from a document as the list of CompressionAlgorithm names it spells, so the flags stay this
crate's own spelling of the setting; an empty list is Compression::empty() — negotiation off.
Retryability is decided from the typed NetError discriminant, never by substring matching: Timeout, Network, and
Status with 5xx / 429 / 408 are Transient; everything else — including Decode, Cancelled, and RetryExhausted —
is Fatal. Error bodies kept in NetError::Status are truncated to 200 chars.
Headers wraps HashMap<String, String> and is case-sensitive. Response extraction lowercases header names on both
native (reqwest already normalizes) and Apple paths; lookups of headers this crate did not normalize must try both cases.
head backfills a missing content-length from the total in content-range (ignoring *) on both paths, so a
HEAD-hostile server still yields a size.