Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.http.typed

HTTP head parsing, writing and body framing over std.io.typed.Reader[E] and Writer[E] preserve the transport cause in FramingError[E], with no stored-error retrieval API. Use request_body or response_body with a parsed HTTP head to validate framing, select the decoder, enforce body limits and recover a completed persistent connection. The lower-level adapters also remain available for callers that have already validated Content-Length and method/status semantics.

import std.bytes
import std.http
import std.io
import std.io.typed
from std.http.typed import FramingError, read_request, request_body
from std.io.typed import CopyFailure, Partial, ReadFailure, copy_all

type HttpCause = FramingError[ReadFailure[std.io.StreamError]]
type ReadBodyError = Partial[HttpCause] | HttpCause | CopyFailure[HttpCause, std.io.StreamError]

def main() -> Result[(), AllocError | IoError | std.bytes.BytesError | std.io.StreamError | std.http.HttpError | ReadBodyError]:
    wire = std.bytes.from_text("POST /notes HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")?
    limits = std.http.limits()
    mut conn = std.io.typed.buffered_reader(std.io.typed.reader(std.io.cursor(wire)), 256)?
    head = read_request(&mut conn, &limits)?.ok_or(std.http.HttpError.Truncated)?
    mut body = request_body(conn, &head, &limits)?
    mut output = std.io.typed.writer(std.io.cursor(std.bytes.empty()?))
    mut scratch = std.bytes.with_capacity(16)?
    scratch.resize(16, 0u8)?
    copy_all(&mut body, &mut output, &mut scratch)?
    print(head.method, head.target, std.io.typed.unwrap_writer(output).data.to_text()?)?
    print(body.total(), body.is_done())?
    # The connection comes back only after a complete, reusable message.
    drop(body.finish()?)
    Ok(())
POST /notes hello
5 True

Reading and writing heads

read_request(reader, limits) and read_response(reader, limits) return Result[Option[RequestHead or ResponseHead], Partial[FramingError[E]]]. They share the legacy parser’s grammar, UTF-8 validation and Limits policy. Nothing means EOF before a message; request parsing permits leading blank lines within the head budget. Response parsing returns each interim response separately. Framing fields and request routing remain separate validation steps.

On failure, completed counts bytes consumed for this head, including CRLF and leading request blank lines. It excludes prefetched bytes still in a buffered reader. Transport errors remain Transport(E); malformed syntax and limits are Protocol(HttpError), and an incomplete head is Truncated. Explicit interruption retries without losing the partially read line. Other errors terminate this parse; the count is diagnostic information, not a token for resuming a partially parsed head.

These functions request one byte at a time and never consume body bytes. For sockets, use std.io.typed.buffered_reader so those requests are served from a buffer; its ReadFailure[E] envelope preserves the original transport payload. Pass the same buffered reader to the body constructor to retain read-ahead.

write_request(writer, head), write_response(writer, head), write_chunk(writer, source: &[u8]) and write_last_chunk(writer, trailers) return Result[(), Partial[FramingError[E]]]. Head/trailer serialization shares std.http’s validation and completes before any write. Chunk payload is written directly from the source without copying it. completed counts wire bytes delivered by that operation, including chunk metadata. Transport errors and invalid progress remain distinct; a zero write is Protocol(HttpError.Stream(StreamError.WriteZero)). Partial output must not be blindly replayed. Empty chunks write nothing; write_last_chunk ends a chunked body.

Wire-progress extents are checked before delivery. In particular, a chunk’s size line, payload and final CRLF must all fit i64; representation failure returns Partial(0, FramingError.OutOfRange) before writing any of that chunk. It is not invalid transport progress. Existing transport failures retain their payload and exact previously delivered count.

Head, body and trailer cursor arithmetic uses explicit opt-outs justified by validated ranges, byte budgets and accepted progress. A caller’s arithmetic mode does not change these public result contracts.

From a parsed head

request_body(inner, head, limits) and response_body(inner, head, method, request_keep_alive, limits) return Result[Body[E, R], FramingError[E]], where R satisfies Reader[E]. The transport must be positioned immediately after the head. Pass the whole buffered reader, including any prefetched body or next-message bytes; taking its inner stream would lose those bytes.

These constructors call request_framing or response_framing, respectively. Duplicate/conflicting framing, unsupported transfer coding and invalid lengths remain Protocol(HttpError) failures, including on HEAD and bodyless responses. HEAD, 1xx, 204, 304 and successful CONNECT semantics select an empty body. Requests without declared framing are empty; responses without declared framing are close-delimited. The constructors accept parsed heads: they do not parse bytes, revalidate arbitrary hand-built head syntax, validate request routing/Host, or consume interim responses on the caller’s behalf.

Every mode checks Limits at construction. A declared payload length above max_body fails before any read. Chunked and close-delimited payloads are bounded while reading. At exactly the close-delimited limit, the next nonempty read probes one byte to distinguish EOF from an oversized body; that probe is not delivered or counted, and an oversized body becomes broken. Empty reads never probe. HEAD/bodyless responses do not spend a body budget on their declared representation length.

Body.read follows the same typed reader contract as the low-level adapters. total() counts payload bytes returned successfully; is_done() is true only after successful completion. trailers() borrows the chunked trailer list (empty for other modes); it is complete only once is_done() is true. Interruption preserves state; failures retain concrete transport causes and previously delivered progress through the typed stream helpers. Invalid ranges trap before changing body state or accessing the transport. Other source/framing failures break it.

body.finish() returns Result[R, FramingError[E]]. It returns the complete transport, with buffered bytes preserved, only after a persistent fixed-length, chunked or empty body completes. An unfinished body reports Protocol(HttpError.Unfinished) and remains readable. A failed or previously finished body reports Broken. Successful extraction happens exactly once; subsequent reads report Broken and is_done() becomes false.

NotReusable means completion did not permit another HTTP message: the connection is close-delimited, has a closing Connection policy, is HTTP/1.0 without keep-alive, is a 101 upgrade or is a successful CONNECT tunnel. For responses, pass the originating request’s keep-alive policy as request_keep_alive; both sides must allow persistence. This is message-boundary recovery, not pooling: after an interim response, the next message is still the final response; after reading a request, the server must also honor its response’s closing policy. Upgraded/tunnel streams are deliberately not exposed by this API. Rejected finish calls retain ownership; dropping the body closes or drops its transport, including on incomplete/error paths. Constructors take ownership too, so a construction failure drops the supplied transport.

Low-level framing

ConstructorFraming
fixed_body(inner, length: NonNegative[i64])Result[FixedBody[E, R], FramingError[E]]
close_body(inner)CloseBody[E, R]; reads until transport EOF
chunked_body(inner, limits)Result[ChunkedBody[E, R], FramingError[E]]; bounded chunked decoding with std.http.Limits

All readers expose read(buffer, start, stop) returning Result[i64, StepError[FramingError[E]]]. Invalid caller buffer ranges trap before checking body state or accessing the transport, as required by the Reader contract. A fixed body caps each read to its remaining length and reports Truncated on premature EOF. It never consumes bytes after the body. A close-delimited body treats EOF as successful completion and remembers it, avoiding further transport reads. A close-delimited connection must never be reused.

FramingError distinguishes Transport(E), Truncated, InvalidProgress, OutOfRange, Broken, NotReusable, and Protocol(HttpError). The latter retains chunk syntax, limit, trailer and framing-allocation errors using the existing HttpError variants; transport errors always use Transport(E). Interruption is forwarded as a retry signal without poisoning the body. Transport errors and invalid progress make it broken; later reads report Broken. Truncation also breaks a fixed or chunked body. Chunk syntax, limit and trailer failures also break the chunked body. Invalid buffer ranges do not consume input or break a healthy body. Valid empty reads return zero without reading or marking EOF.

Chunked bodies

chunked_body requires a validated head selecting chunked transfer coding. It checks Limits at construction (max_line and max_head at least two; the types of max_headers and max_body already exclude negative values). Chunk-size lines and trailer fields include CRLF in their line limit. max_head bounds the entire trailer section, including its final CRLF; max_headers bounds trailer fields, and max_body bounds decoded payload. A chunk exceeding the remaining body budget is rejected before its payload is read. Hex sizes are overflow-checked.

The decoder requires CRLF and uses the same extension and field policy as std.http: extensions after ; are ignored after rejecting control bytes other than tab; field names must be tokens, surrounding spaces/tabs are removed from values, and values must be valid UTF-8 without other control bytes or DEL. Obsolete folded fields are rejected. Trailers are metadata, not a second head: they do not change framing or replace validated head fields. Parsed fields are available in body.trailers; treat them as complete only after body.is_done(). body.total() counts decoded payload bytes returned to the caller.

Parsing resumes after Interrupted even within a size line, a CRLF, or a trailer. A successful payload read returns immediately, before parsing subsequent framing. Consequently read_exact and copy_all retain all previously returned payload progress when later metadata or transport fails. Metadata bytes do not count as delivered bytes. As with other readers, an unsuccessful read may have modified scratch storage; only an Ok(count) identifies payload to use.

The terminal blank trailer line completes the body. Metadata reads request one byte at a time, and payload reads are capped to the current chunk; the reader does not request any bytes from the next message. Repeated completed reads return zero without consulting the transport. Empty reads do not advance parsing or imply completion. Construction reserves one line buffer of max_line bytes; trailer strings and their list allocate fallibly while reading. This simple decoder prioritizes bounded, resumable behavior; it has no internal read-ahead buffer or throughput qualification yet.

The adapters own their transports. Connection pooling, TLS setup and cancellation polling belong to std.http.client, which uses these typed head and body paths, including CONNECT and streamed uploads. Cancellation delivered as a typed transport cause survives intact. The client’s legacy Response.read/take_failure bridge remains for compatibility; its internal transport and framing paths do not retrieve stored errors. The low-level adapters do not expose their transports; use the parsed-head constructors and Body.finish for guarded connection recovery. Bridging a legacy head reader preserves only the error type that the legacy reader itself returns.