the fetch contract #
what Client.fetch promises, and why each promise exists. field names and
defaults live on the types in src/client.zig; this document explains the
shape, not the numbers.
phases and their guards #
a request passes through phases that fail differently, so each has its own guard:
| phase | what goes wrong | guard | error |
|---|---|---|---|
| connect, TLS, send, wait for head | a SYN that goes unanswered, or a peer that accepts and never replies. the kernel gives up in minutes, or never on an established socket | head_deadline_ns |
ResponseHeadTimeout |
| body, no bytes | a peer that sends a head and then nothing | stall.idle_ns |
TransferStalled |
| body, trickle | bytes arrive, but too slowly to ever finish. an idle timeout never fires on this | stall.min_bytes_per_s over a trailing stall.window_ns after stall.grace_ns |
TransferTooSlow |
| whole request | the caller has a budget the phases above do not add up to | deadline_ns |
RequestDeadlineExceeded |
| body size | a response the caller has no business buffering | max_response_size |
ResponseTooLarge |
the rate guard measures over a trailing window rather than the transfer's lifetime. a body that arrived fast and then trickles would otherwise be judged on its average for as long as the burst was large.
the head guard and the stall guard are watchers over a task: a blocking read
cannot check a clock, so the read runs as a task and the watcher cancels it
when progress dies. deadline_ns reuses the head watcher and keeps it
running through the body.
the task is started with io.concurrent, since a watcher only bounds work
that runs beside it. an Io with no concurrency to give (a single-threaded
build, std.Options.debug_io, a concurrent_limit already spent) runs the
request on the caller's stack with none of these guards, and logs one
warning per process under the zttp scope.
replay #
a pooled keep-alive connection the origin closed while it sat idle fails on
the next request with a connection error, before any byte of a response is
seen. that failure is indistinguishable from a real one except for one fact:
nothing was delivered, so the request cannot have taken effect. fetch
replays such a failure on a fresh connection, up to
dead_connection_attempts, but only for requests that are idempotent by
method and carry no body.
a connection a middlebox silently drops delivers no error at all. only the
head deadline catches the next request on it, and nothing replays that: the
caller sees ResponseHeadTimeout once, and the abandoned socket is not
re-pooled.
redirects #
zig 0.16's std.http.Client accepts privileged_headers and strips them
when a redirect leaves the domain, but never writes them to the request. a
token handed to std therefore follows a redirect to whoever the origin names.
fetch sends with redirect_behavior = .unhandled and follows 3xx itself:
- at most
redirects.max_hops, with a target already visited in the chain beingRedirectLoop. - https to http is
SchemeDowngradeunlessredirects.allow_scheme_downgrade. authorizationis dropped whenever a hop changes scheme, host, or port.- 303, and 301/302 on POST, become a bodiless GET. 307 and 308 resend the
body. any other 3xx is
HttpRedirectUnsupported. on_redirect = .surfacereturns the 3xx itself;.refusemakes itRedirectRefused.- a request on a
resolved_connectionis never redirected: the caller vetted that address, and any other would be dialed unvetted. its 3xx is surfaced.
res.final_url records where a followed chain ended.
input #
a URL containing a control character, or a header containing CR or LF, is
InvalidUrl or InvalidHeader before any dial. std writes the request line
verbatim and only asserts on headers, so outside input could inject a header
or crash a safe build. a payload without content_type is
MissingContentType rather than a request with no content-type header.
streaming #
fetch is open followed by a stream into memory. a body that has no
business in memory, or a sink that depends on the status, uses the two
halves directly:
const response = try client.open(.{ .url = url });
defer response.deinit();
if (response.status == .ok) {
_ = try response.stream(&file_writer.interface, .{ .progress = &progress });
} else {
_ = try response.stream(&fixed.writer, .{ .max_bytes = fixed.len });
}
open returns once the head is in, with every request-phase guard already
applied: the head deadline, dead-connection replay, the redirect policy,
input validation. stream decodes and drains the body into any
std.Io.Writer under the stall guard, an optional byte cap, and whatever
deadline_ns has left after the head. Progress is a pair of counters
another task may read while the body moves. deinit closes the request
whether or not the body was taken.
headers back #
capture_headers names the response headers to keep; res.headers.get
reads them case-insensitively, first occurrence winning. everything else in
the head is gone once the body has been read.
res.rate_limit carries RateLimit-* and Retry-After in both its delta
and http-date forms. delaySeconds(now) turns them into one wait, ranking
Retry-After first and reading a RateLimit-Reset at or past 1e9 as an
absolute time.
ownership #
FetchResult.deinit takes an allocator, and it must be the one the client
was built with. captured header names alias the caller's capture_headers
slice; values are owned copies.
concurrent fetch calls on one client are safe: std's connection pool and
CA bundle take their own locks. the configuration fields are plain values.
set them before sharing the client and use the per-request overrides after.
identity #
the default User-Agent names this library and where to find it. anything
calling other people's servers at scale should name itself instead, via
initWithUserAgent or the user_agent field, so an operator seeing load
knows what is calling and where to ask.