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
| Item | Meaning |
|---|---|
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:
| Field | Bounds | limits() |
|---|---|---|
max_line | One request, status, field or chunk-size line, including its CRLF | 8192 |
max_head | All bytes of a head, blank lines before a request line included; also a trailer section | 65536 |
max_headers | Field lines in a head or trailer section | 100 |
max_body | Decoded body bytes | 1048576 |
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
| Function | Result |
|---|---|
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.
| Function | Result |
|---|---|
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:
| Fields | Error |
|---|---|
Content-Length and Transfer-Encoding together | ConflictingFraming |
| Either field twice, even with equal values | DuplicateFraming |
Content-Length other than decimal digits (+5, 1,1, empty), or above i64 | InvalidContentLength |
Transfer-Encoding other than exactly chunked, or in HTTP/1.0 | UnsupportedTransferEncoding |
The framing functions check both fields even where the status decides the framing, so a malformed response is rejected consistently.
Targets and Host
| Function | Result |
|---|---|
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.
| Request | Result |
|---|---|
HTTP/1.1 without Host | MissingHost |
More than one Host field, in any version | DuplicateHost |
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 scheme | InvalidTarget |
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 method | Result |
|---|---|
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.trailers | Trailer 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
| Function | Result |
|---|---|
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
| Variant | Cause |
|---|---|
Stream(StreamError) | The underlying stream failed, or a range was out of bounds |
Allocation(AllocError) | Memory for a line, field, or output ran out |
InvalidLimit | max_line or max_head below 2 |
Truncated | Input ended inside a head, a body, or a chunk |
LineTooLong, HeadTooLarge, TooManyHeaders, BodyTooLarge | A limit was reached |
BareLineFeed | A line ended in LF without CR |
InvalidRequestLine, InvalidStatusLine, InvalidHeader | Syntax outside the grammar above |
UnsupportedVersion | Not HTTP/1.0 or HTTP/1.1 |
InvalidContentLength, DuplicateFraming, ConflictingFraming, UnsupportedTransferEncoding | See Framing |
InvalidChunk | A chunk-size line or chunk terminator outside the grammar |
LengthMismatch | write_body given a range that does not match the framing |
Unfinished, Broken | finish before the end, or after a failure |
InvalidTarget, MissingHost, DuplicateHost, InvalidHost | See 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 onestrper 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
BufReaderinto the caller’s buffer; nothing is retained.read_to_endgrowsout4096 bytes at a time;discarduses one 4096-byte scratch buffer. - A writer builds each head or chunk-size line in one
Bytesand writes it; chunk data is written from the caller’s buffer without copying. - Heads,
LimitsandFramingare ordinary owned values. ABodyReaderowns its connection, so it moves to a worker with it whenRcan. 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-continuehandling beyond returning the interim response. Upgrades are not handled here:std.http.serverhands a101connection tostd.http.websocket, anddetachhands a long-lived response tostd.http.sse. - No connection pool, redirects, proxies, cookies, or TLS here; the server
is
std.http.serverand the clientstd.http.client. Deadlines are the caller’s: usestd.net’s*_beforecalls, or read with a deadline-awareReader. - Only the
chunkedtransfer coding. A body with another coding is rejected, not decoded. read_requestreturns the target as text;request_targetsplits the forms a server accepts. Usestd.urlfor other URLs.- Field values must be UTF-8; a peer that sends Latin-1 bytes is rejected.
Acknowledgements
- Bundling a client and a server follows the scope of Go’s
net/http.