zttp docs client.md
6.1 kB · 128 lines
Markdown
at main

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 being RedirectLoop.
  • https to http is SchemeDowngrade unless redirects.allow_scheme_downgrade.
  • authorization is 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 = .surface returns the 3xx itself; .refuse makes it RedirectRefused.
  • a request on a resolved_connection is 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.