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

import std.http reads and writes HTTP/1.1 messages (RFC 9112) over std.io streams: request and response heads, Content-Length, chunked and close-delimited bodies, and the connection reuse rules. It is the framing layer only. std.http.server builds a server on it and std.http.client a client; their HTTPS adapters use std.tls.

std.http.decoder exposes the same request grammar as a bounded byte-feed state machine without I/O. The actor HTTP server uses it to suspend between readiness events while keeping parsing state in its connection heap.

For body transfers that must preserve a concrete transport error type, the additive std.http.typed readers support fixed-length, chunked and close-delimited framing over std.io.typed. They assume validated heads; the clients and servers above continue to use this module’s framing API.

The module is checked Plenty in stdlib/std/http.plenty; it adds no runtime primitives. The same code runs over a std.net TcpStream, a Cursor in a test, or any other Reader and Writer.

import std.bytes
import std.http
import std.io
from std.http import HttpError

def main() -> Result[(), AllocError | IoError | HttpError | std.io.StreamError | std.bytes.BytesError]:
    wire = "POST /notes HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello"
    limits = std.http.limits()
    mut conn = std.io.buffered_reader(std.io.cursor(std.bytes.from_text(wire)?), 1024)?
    head = std.http.read_request(&mut conn, &limits)?.ok_or(HttpError.Truncated)?
    framing = std.http.request_framing(&head)?
    mut body = std.http.body_reader(conn, &framing, &limits)?
    mut data = std.bytes.empty()?
    std.http.read_to_end(&mut body, &mut data)?
    print(head.method, head.target, data.to_text()?)?

    mut reply = std.http.response(201, "Created")?
    reply.add("Content-Length", "0")?
    print(str.repr(std.http.encode_response(&reply)?.to_text()?)?)?
    Ok(())
POST /notes hello
"HTTP/1.1 201 Created\r\nContent-Length: 0\r\n\r\n"

Messages

ItemMeaning
Header(name: str, value: str)One field line
RequestHead(method, target, minor: i64, headers: list[Header])minor is the HTTP/1.x minor version, 0 or 1
ResponseHead(minor, status: i64, reason: str, headers)
request(method, target), response(status, reason)Result with a head using HTTP/1.1 and no fields; fails only when allocating the field list fails
head.add(name, value)Result[(), HttpError]: append a field
find(headers: &list[Header], name: &str)Option[i64]: index of the first field named name, ignoring ASCII case
same_name(left: &str, right: &str)bool: whether two names are equal ignoring ASCII case; never allocates, and non-ASCII text never matches

Parsed names keep the case they arrived in. Values have surrounding spaces and tabs removed. Fields stay in arrival order and repeated names stay separate; nothing is combined.

Limits

Limits(max_line, max_head, max_headers, max_body) bounds every read:

FieldBoundslimits()
max_lineOne request, status, field or chunk-size line, including its CRLF8192
max_headAll bytes of a head, blank lines before a request line included; also a trailer section65536
max_headersField lines in a head or trailer section100
max_bodyDecoded body bytes1048576

max_headers and max_body are NonNegative[i64], so a negative count is a type error; read them with .get(). max_line and max_head must be at least 2, or the call fails with InvalidLimit. Memory use is one line buffer of at most max_line bytes plus the parsed fields; the body is never held unless the caller collects it.

Reading heads

FunctionResult
read_request(conn: &mut BufReader[R], limits)Result[Option[RequestHead], HttpError]
read_response(conn: &mut BufReader[R], limits)Result[Option[ResponseHead], HttpError]

Nothing means the input ended before the message’s first byte, as when the peer closes an idle connection; an end inside a head is Truncated. Blank lines before a request line are skipped within max_head, and input that ends after only blank lines is Nothing. A 1xx response is returned like any other; read again for the final response.

Parsing is strict. Every line ends in CRLF; a bare LF is BareLineFeed, and a CR inside a line is invalid. A request line is a token method, one space, a target of visible ASCII, one space, and HTTP/1.0 or HTTP/1.1. A status line is the version, a space, three digits from 100, and an optional space and reason; HTTP/1.1 200 with no reason is accepted. A well-formed other version such as HTTP/2.0 is UnsupportedVersion. A field line is a token name, a colon with no space before it, and a value of visible bytes, spaces and tabs that is valid UTF-8. A line starting with a space or tab (an obsolete line fold) is InvalidHeader.

parse_request_line(line: &[u8]), parse_status_line(line: &[u8]) and parse_field(line: &[u8]) expose the same grammar for custom readers. line excludes CRLF; pass &buffer[:size] for a prefix. These helpers do not read streams or enforce head budgets. For typed transports, prefer std.http.typed.read_request and read_response, which enforce those budgets and retain causes and consumed-byte progress.

Framing

Framing is Empty, Length(i64), Chunked, or UntilClose.

FunctionResult
request_framing(head: &RequestHead)Result[Framing, HttpError]; no framing field is Empty
response_framing(head: &ResponseHead, method: &str)Result[Framing, HttpError]; no framing field is UntilClose
request_keeps_alive(head)HTTP/1.1 unless Connection: close; HTTP/1.0 only with Connection: keep-alive
response_keeps_alive(head, framing: &Framing)As for requests, and never after an UntilClose body

method is the method of the request a response answers. Responses to HEAD, 1xx, 204 and 304 responses, and 2xx responses to CONNECT are Empty whatever their fields say.

Framing that two implementations could read differently is an error, never a guess, because that disagreement is how request smuggling works:

FieldsError
Content-Length and Transfer-Encoding togetherConflictingFraming
Either field twice, even with equal valuesDuplicateFraming
Content-Length other than decimal digits (+5, 1,1, empty), or above i64InvalidContentLength
Transfer-Encoding other than exactly chunked, or in HTTP/1.0UnsupportedTransferEncoding

The framing functions check both fields even where the status decides the framing, so a malformed response is rejected consistently.

Targets and Host

FunctionResult
request_target(head: &RequestHead)Result[RequestTarget, HttpError]: the checked authority, path and query

RequestTarget has authority, path and query, all str. path and query are still percent-encoded; query follows the first ? and is empty without one, and an empty absolute-form path is /.

request_target applies the server rules of RFC 9112 section 3.2. The parser reads a head without them, so a client or proxy can still read any well-formed message; a server calls this before routing, as std.http.server does.

RequestResult
HTTP/1.1 without HostMissingHost
More than one Host field, in any versionDuplicateHost
A Host value other than uri-host [ ":" port ]InvalidHost
Origin-form target (/path?query)authority is the Host value; empty for HTTP/1.0 without one
Absolute-form target (http://authority/path?query, or https, scheme in any case)authority is the target’s, replacing Host
Absolute-form with userinfo (@), an empty host, or another schemeInvalidTarget
Asterisk-form (OPTIONS *) or authority-form (CONNECT host:port)InvalidTarget

Host is checked even when an absolute-form authority replaces it. An empty Host value is valid; a client sends one when the target has no authority. A host is a reg-name of unreserved, sub-delimiter and percent-encoded bytes, or an IP literal in brackets. An IPv6 literal is checked for its characters, not parsed as an address; an IPvFuture literal must match "v" 1*HEXDIG "." 1*(unreserved / sub-delims / ":"). A port is digits.

Decimal Content-Length and hexadecimal chunk-size accumulation use local checked results. Every nonnegative value through i64.MAX is representable; larger values return InvalidContentLength or InvalidChunk, not an arithmetic trap. Body limits are enforced separately, so a representable length can still be BodyTooLarge. Bounded line, header and body cursors use documented bounds-proven arithmetic opt-outs.

Reading bodies

Function or methodResult
body_reader(conn: BufReader[R], framing: &Framing, limits)Result[BodyReader[R], HttpError]; takes the connection
body.read_body(buffer, start, stop)Result[i64, HttpError]: decoded bytes; 0 from a non-empty range is the end of the body, and an empty range returns 0 without reading
body.read(buffer, start, stop)The std.io Reader method
read_to_end(body, out: &mut Bytes)Result[i64, HttpError]: append the rest
discard(body)Result[i64, HttpError]: read and drop the rest
finish(body: &mut BodyReader[R])Result[BufReader[R], HttpError]: the connection, at the next message
release(body)Option[BufReader[R]]: the connection in any state
body.trailersTrailer fields, once a chunked body has ended
body.total(), body.is_done()Bytes delivered; whether the end was reached
body.take_failure()Option[HttpError]: the framing error behind a read failure

The body reader owns the connection, so nothing can read the next message while a body is in progress. finish returns it only after the body’s end, including buffered bytes of pipelined messages; a body with bytes left is Unfinished and a failed one is Broken. release returns the connection regardless, for writing a final error response or closing; its read position is then unknown, so it must not carry another message.

A Length body larger than max_body fails before reading. A chunked body fails as soon as a chunk size would pass max_body, and an UntilClose body as soon as a byte arrives past it. Chunk sizes are hexadecimal without signs or leading spaces. Chunk extensions after ; are checked for control bytes and ignored. Each chunk’s data must be followed by CRLF.

parse_chunk_size(line: &[u8]) exposes that chunk-size grammar for custom framed readers. It reads a &[u8] view of one line excluding CRLF, such as &line[:size], and returns Result[i64, HttpError] without allocating. Extensions are checked and ignored. Invalid syntax or a size exceeding i64 returns InvalidChunk. Callers enforce their own line and body limits.

Through read, a body is an ordinary Reader for std.io.copy_all and similar helpers. Stream and allocation errors keep their StreamError; Truncated becomes UnexpectedEof; any other framing error becomes Io(IoError.InvalidInput) and is kept for take_failure. After any error the body reports only Broken.

read_to_end checks its next output-buffer extent before growing the buffer or reading another chunk. An unrepresentable extent is Allocation(AllocError.CapacityOverflow) and leaves the existing output and connection position unchanged for that iteration. Earlier appended bytes remain.

Writing

FunctionResult
write_request(writer: &mut W, head)Result[(), HttpError]
write_response(writer, head)Result[(), HttpError]
write_chunk(writer, source: &[u8])One chunk; an empty view writes nothing
write_last_chunk(writer, trailers: &list[Header])The zero-size chunk and trailer section
write_body(writer, framing, source: &[u8])A whole body under framing; Length and Empty must match len(source)

Writers validate before writing and write nothing when validation fails: token methods and names, targets of visible ASCII, reasons and values without CR, LF or other control bytes, values without surrounding blanks, status 100–999, and the framing rules above. A value cannot add a field of its own. A stream failure may leave part of a message delivered. Heads are written with one write_all; a chunk with three. The caller flushes.

encode_request(head), encode_response(head), encode_last_chunk(trailers) and encode_chunk_size(length: NonNegative[i64]) return Result[Bytes, HttpError]. They perform the same validation and serialization without writing, sharing implementation with the typed writers. A chunk length of zero encodes 0\r\n without the final trailer-section terminator.

Errors

VariantCause
Stream(StreamError)The underlying stream failed, or a range was out of bounds
Allocation(AllocError)Memory for a line, field, or output ran out
InvalidLimitmax_line or max_head below 2
TruncatedInput ended inside a head, a body, or a chunk
LineTooLong, HeadTooLarge, TooManyHeaders, BodyTooLargeA limit was reached
BareLineFeedA line ended in LF without CR
InvalidRequestLine, InvalidStatusLine, InvalidHeaderSyntax outside the grammar above
UnsupportedVersionNot HTTP/1.0 or HTTP/1.1
InvalidContentLength, DuplicateFraming, ConflictingFraming, UnsupportedTransferEncodingSee Framing
InvalidChunkA chunk-size line or chunk terminator outside the grammar
LengthMismatchwrite_body given a range that does not match the framing
Unfinished, Brokenfinish before the end, or after a failure
InvalidTarget, MissingHost, DuplicateHost, InvalidHostSee Targets and Host

Costs, ownership and threads

  • Reading a head is linear in its bytes. It allocates one line buffer, which grows to the longest line read (at most max_line), plus one str per method, target, reason, name and value, and the field list.
  • A body reader allocates its line buffer and trailer list once. Body bytes are copied once, from the BufReader into the caller’s buffer; nothing is retained. read_to_end grows out 4096 bytes at a time; discard uses one 4096-byte scratch buffer.
  • A writer builds each head or chunk-size line in one Bytes and writes it; chunk data is written from the caller’s buffer without copying.
  • Heads, Limits and Framing are ordinary owned values. A BodyReader owns its connection, so it moves to a worker with it when R can. Nothing is shared or global, and no call starts a thread or waits beyond the underlying stream’s own calls.
  • Supported targets are those of std.io: x86_64 Linux GNU.

Example

The tutorial lesson reads pipelined requests from memory and writes a chunked response. The tests in tests/test_std_http.rs serve several requests on one loopback connection.

Limits

  • HTTP/1.0 and HTTP/1.1 only. No HTTP/2, or 100-continue handling beyond returning the interim response. Upgrades are not handled here: std.http.server hands a 101 connection to std.http.websocket, and detach hands a long-lived response to std.http.sse.
  • No connection pool, redirects, proxies, cookies, or TLS here; the server is std.http.server and the client std.http.client. Deadlines are the caller’s: use std.net’s *_before calls, or read with a deadline-aware Reader.
  • Only the chunked transfer coding. A body with another coding is rejected, not decoded.
  • read_request returns the target as text; request_target splits the forms a server accepts. Use std.url for other URLs.
  • Field values must be UTF-8; a peer that sends Latin-1 bytes is rejected.

Acknowledgements