# 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: ```zig 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.