Standard library Dodo 0.1.4

HTTP

Portable, bounded HTTP/1.1 framing and explicit transport composition.

On this page

HTTP exchanges a request and a response. Each has a start line, headers, and a body whose boundaries are defined by framing rules. A successful HTTP exchange can still return a status such as 404; the status is application information, separate from a transport or parsing failure.

Start with std/http/hosted.Client to fetch a URL on Linux GNU x86-64 or Windows x64. It handles resolution, connection, and polling with explicit storage and limits. Continue into the portable parser and sender when you need custom transports, incremental bodies, or control over each I/O step.

Choose an HTTP layer

Layer Use it when You supply
std/http/hosted or std/http/https You want a complete URL request. Workspace, body sink/buffer, policy; explicit trust for HTTPS.
std/http/client You already own a transport and execution loop. Origin, three buffers, clock observations, polling, and readiness.
std/http/connection You are composing a client/server protocol driver. Transport, buffer storage, event/body consumption, and execution policy.
std/http You need HTTP values, parsing, or serialization only. Input bytes and bounded output/workspace.
std/web/app You want routes, handlers, and a hosted server. Application behavior and explicit server limits.

The portable layers do not open connections or read clocks. Importing HTTP does not select TLS; choose the HTTPS adapter or a verified custom transport.

Quickstart

Create a fresh directory for a Python 3.11+ local HTTP peer in terminal 1:

python3 -c "from pathlib import Path; Path('local-http').mkdir(exist_ok=True); Path('local-http/index.html').write_bytes(b'Hello, Dodo!\n')"
python3 -m http.server 8080 --bind 127.0.0.1 --directory local-http --protocol HTTP/1.1

Wait for the serving message, then use terminal 2. If port 8080 is occupied, stop your previous example or change the port in both places. No Internet, proxy configuration or credentials are involved. --protocol HTTP/1.1 is required because Dodo rejects HTTP/1.0 responses.

Save this as fetch_url.dodo:

package fetch_url
import "std/http/hosted"
import "std/http/hosting"
import "std/console"
import "std/io"

fn fetch() -> i32!hosting.Error {
    workspace := [0u8; hosted.WORKSPACE_BYTES]
    client := hosted.Client.new(&mut workspace, hosted.Config.defaults())?
    body := [0u8; 1024]
    response := client.get(b"http://127.0.0.1:8080/", &mut body)?
    if response.status != 200 { return ok(2) }
    output := console.stdout()
    match io.write_all(&mut output, &body[..response.body_bytes as usize]) {
        ok(_) => { return ok(0) }, err(_) => { return ok(3) },
    }
}
fn main() -> i32 {
    match fetch() {
        ok(code) => { return code },
        err(reason) => {
            if reason.kind == hosting.ErrorKind.BodyLimit { return 4 }
            errors := console.stderr()
            match errors.println(&reason) {
                ok(_) => {}, err(_) => {},
            }
            return 1
        },
    }
}
dodo run fetch_url.dodo

Expected stdout is Hello, Dodo! followed by a newline; exit 0 means a complete HTTP 200 response was received. This also works against the web quickstart. Stop the Python server with Ctrl+C when finished.

hosted.WORKSPACE_BYTES supplies fixed parser/header/I/O storage; the 1024-byte array independently bounds the response body. std/http/hosting names shared hosted errors; its driving helpers are implementation plumbing, not the recommended application entry point. console selects stdout and io.write_all handles partial writes.

Exit 1 means the request failed: start the local server for a connect error, check the URL for resolution errors, and inspect the nested network, transport or protocol cause. Exit 4 means the body exceeded your bound; increase it within a chosen limit or use get_to with a streaming sink. Error delivered counts body bytes already accepted by the sink. Exit 2 is an HTTP status other than 200, which is a completed response, not a transport failure. Exit 3 means stdout failed.

Hosted client choices

Config.defaults() disables redirects and sets explicit positive resolution, connection and request budgets. Configure max_redirects only when following locations is intended. System resolution cannot interrupt getaddrinfo; its budget is checked before and after that call. Deadlines bound cooperative I/O, not arbitrary native blocking or application work.

get_to(url, sink) streams to an std/io writer; request accepts explicit method, headers and borrowed body bytes. header(name, occurrence) exposes the final response’s copied headers until the next request, and errors clear them. request_with selects resolver, connector, clock and cancellation providers. The default connector is plaintext HTTP. For HTTPS, start with std/http/https.Client and explicit https.Trust, as shown in the TLS quickstart. Never disable peer verification to make a request succeed. std/http/connection and the polling std/http/client are the advanced portable path described below.

API and contracts

std/http contains HTTP types and an incremental HTTP/1.1 engine. Importing it requires only portable core/ascii, core/bytes, core/mem, and std/net address parsing. It performs no allocation, socket operation, clock read, filesystem access, TLS operation, or thread creation. Transport composition belongs to std/http/connection, std/http/client, and std/http/server; TLS backend choice remains external.

The wire grammar and framing follow RFC 9112, message semantics follow RFC 9110, and URI components follow RFC 3986. The supported wire version is HTTP/1.1. HTTP/1.0 fallback, HTTP/2, HTTP/3/QUIC, WebSockets, cookies, multipart parsing, and compression are outside this package. Unknown content encodings are delivered as opaque body bytes.

Types and ownership

Method.new(bytes) validates a case-sensitive token. Extension methods are accepted; idempotent() recognizes the standard idempotent methods. Status.new accepts codes 100–599. Header holds borrowed name/value byte slices. Header names compare using ASCII case folding; values remain bytes, including obs-text. Request contains method, request target, headers, and Body; Response contains numeric status, reason, headers, and Body. These are head descriptions, not owners of a buffered body. Body.empty(), Body.sized(n), and Body.streaming() describe no body, content-length, and chunked framing. BodyKind.Close is available for close-delimited responses.

Uri.parse accepts origin-form paths, *, and absolute HTTP(S) URIs, validates percent escape spelling, and preserves encoded bytes. URI accessors borrow the original input. Userinfo, fragments, spaces, controls, raw non-ASCII, and backslashes are rejected. Scheme and origin comparisons use ASCII case folding; origin comparison is conservative and does not normalize default ports, DNS aliases, or percent-encoded host spellings. Path decoding and routing are separate decisions in std/web; + is not a space in a path. An absolute URI with no path has an empty path() accessor, which a client should serialize as /. CONNECT authority-form is accepted separately by the wire parser/serializer and requires an explicit numeric port. IPv6 authorities require brackets; malformed IP literals and ports above 65535 are rejected. Scoped IPv6 literals and IPvFuture authority syntax are outside this implementation. Asterisk-form is accepted only for OPTIONS.

The compiler checks parser workspace, URI, header, request, and response lifetimes. Parser.target(), method(), and field(offset, length) return borrowed views; a live view prevents parser mutation or releasing its backing storage. Event values contain offsets, never references into a transient input buffer. A caller must obtain views only from their corresponding parser event: offsets do not carry a message generation identifier.

Incremental parsing

Parse a complete in-memory request

You can learn the parser without a socket. This complete test repeatedly feeds the unconsumed suffix of one HTTP/1.1 request and counts its body bytes:

package parse_request
import "std/http"

@test
fn reads_a_framed_body() {
    workspace := [0u8; 1024]
    parser := http.Parser.new(http.Role.Request, &mut workspace, http.Limits.defaults())
    input := b"POST /items HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\n\r\nabc"
    cursor := 0usize
    body_bytes := 0usize
    for true {
        event := parser.feed(&input[cursor..])!
        cursor += event.consumed
        if event.kind == http.EventKind.Body {
            body_bytes += event.length
        }
        if event.kind == http.EventKind.Complete { break }
        assert(event.kind != http.EventKind.NeedInput,
            "the complete test message must provide every required byte")
    }
    assert_eq(cursor, input.len)
    assert_eq(body_bytes, 3usize)
    assert(parser.reusable())
}

One feed can consume fewer bytes than it receives because it stops at the next event. It may also emit an event while consuming zero bytes. The loop therefore advances by event.consumed, not by the length of the supplied input. In a network program, NeedInput means to obtain more bytes; it does not mean EOF. The test asserts against it because all bytes are already available.

Drive events and retain the right storage

Create Parser.new(Role.Request, &mut workspace, Limits.defaults()) for a server or Role.Response for a client. Call feed(input) and advance the input cursor by Event.consumed. Continue feeding the unconsumed suffix. A single call produces one event. No read-ahead past that event is lost, including pipelined messages or bytes after a protocol upgrade.

Event Meaning
NeedInput Supplied input exhausted; retain parser state and supply more bytes.
StartLine Method/target or response status is available.
Header Name/value offsets identify validated bytes in parser workspace.
HeadersComplete Validated framing is fixed; the body can be streamed.
Body offset and length index this call’s input, already dechunked.
Trailer Name/value offsets identify a trailer in workspace.
Informational A 1xx response other than 101 is complete; next feed starts the next response.
Complete This message has ended.
Upgrade Switch protocols; all unconsumed bytes belong to the new protocol.

Call feed(empty) after consuming a complete head or final length-delimited body to obtain pending zero-consumption events. Chunked messages emit Complete while consuming the final trailer CRLF. Stop feeding after Complete until next_message() succeeds. This explicit transition prevents silently treating unconsumed bytes as another message. next_message() requires reusable(); connection-close, upgrade, incomplete, and poisoned states cannot be reused. It resets HEAD/CONNECT association, which must be set for each new request.

Set expect_head(true) before parsing a response to HEAD; representation Content-Length is allowed but no body is consumed. Set expect_connect(true) for a CONNECT response; a successful response transitions to Upgrade at the head boundary. 101 requires Connection: upgrade and a nonempty Upgrade field. The caller must additionally validate the selected upgrade protocol against its offer before handing over the stream. Other 1xx responses restart the parser without ending the outstanding request. Responses to 204 and 304 have no body. Requests without framing have no body. Other responses without explicit framing are close-delimited and permanently ineligible for reuse.

At transport EOF call eof(). Only a close-delimited body or an already complete message ends successfully. Truncated start lines, headers, content-length, chunks, or trailers fail with UnexpectedEof and poison the parser. Empty feed input does not mean EOF. Transport/TLS failures must call poison(); a TLS truncation error must never be presented as clean transport EOF.

Strict framing and errors

Bare LF, bare CR, obsolete folding, whitespace before a colon, invalid field names/values, missing or duplicate Host, invalid decimal/hexadecimal lengths, and length overflow are rejected. Content-Length combined with Transfer-Encoding is rejected in either order. Duplicate Content-Length is rejected even when its values agree; comma lists are rejected. Only a single chunked transfer coding is implemented. Unsupported or repeated transfer codings fail instead of being interpreted as another framing mode. Content-Length or Transfer-Encoding on 1xx and 204 responses is rejected.

Chunk extensions support tokens and quoted strings with escaped bytes; they are validated and discarded. Trailers are emitted separately and never merged into headers. Framing, routing, connection, authorization, and representation metadata fields cannot appear as trailers. Applications should further restrict trailer names to those explicitly meaningful for their own protocol.

All parser errors permanently poison the connection. Error.kind distinguishes syntax, framing, resource limits, truncation, state, and bounds failures. position identifies the relevant input/workspace location; an error is not a recoverable input-progress report. Close the invalid connection instead of trying to resume parsing or returning it to a pool. Request/header/body bytes and credential values are never included in diagnostic formatting.

Bounded workspace and backpressure

Limits.defaults() sets 16,384 head bytes, 100 header fields, 16,777,216 body bytes, 1,024 bytes per chunk-size line, 4,096 trailer bytes, and 32 trailer fields. Counts include line delimiters where applicable. The body limit applies to decoded body bytes and is checked before producing an oversized event. A Content-Length exceeding it fails at the head boundary. Chunk sizes are checked against remaining body allowance before accepting the chunk.

The caller selects workspace size. Headers and start-line bytes remain intact throughout the message. Chunk-size lines reuse the workspace suffix; trailers accumulate after the head. Reserve header_bytes + max(chunk_line_bytes, trailer_bytes) to accommodate every configured limit. Smaller storage is supported and yields BufferFull if exhausted; this is terminal because part of a message may already have been consumed. No heap allocation can fail inside the engine. Increasing a limit never causes an allocation.

Body events directly view the supplied input. A consumer processes or copies that fragment before reusing the input storage. Backpressure means simply stopping calls to feed: the engine starts no work on its own. Header and body deadlines, cancellation, scheduling, and transport operation ownership belong to the composing connection provider. poison() invalidates a cancelled exchange; retry policy never turns an incomplete exchange into a reusable connection.

Serialization and streaming output

Serialization has two stages: write the head, then frame and deliver the body. write_request and write_response write into memory; they do not send those bytes to a socket. Likewise, Sender.frame reports encoded bytes ready for delivery, not bytes received by a peer.

For a length-delimited body, declare the length before sending and finish only after accepting exactly that many bytes. For a chunked body, stage chunks as data becomes available and finish with the final zero chunk. In both cases, retain unsent encoded bytes across partial transport writes.

write_request(&request, destination) and write_response(&response, destination) produce a complete head into caller memory and return its length. Publish only a successful returned prefix: errors can leave unpublished partial bytes in the buffer. Field values cannot inject CR/LF. The Body descriptor is the sole source of framing headers; manually supplied Content-Length or Transfer-Encoding is rejected. Ordinary no-body responses receive Content-Length: 0; bodyless 1xx/204/304 do not. A Close response receives Connection: close if the caller has not supplied it.

Sender.new(body) tracks streaming output independently of a writer. frame(input, destination) returns Encoded { consumed, written }; send the written prefix and preserve it until the transport reports that it is fully accepted. A short output buffer produces partial body progress. Chunked framing requires at least 21 destination bytes. Empty input emits no chunk and does not end the body. Call finish(trailers, destination) to produce the zero chunk and trailers, or to validate completion of a fixed-length body. complete() becomes true only after finish succeeds. The caller still owns the obligation to flush the final bytes.

BufferFull on Sender output is retryable with more workspace and does not change its state. Too many or too few length-delimited bytes, invalid trailers, output after finish, and other state violations poison it. Transport failures must call poison(). A sender has no implicit flush, destructor I/O, body replay, allocation, or deadline.

Explicit policies

Different layers report different kinds of incompleteness:

Signal May you retry on the same state? Required action
Parser NeedInput Yes Supply the next bytes, or call eof() only for clean transport EOF.
Driver Backpressure Yes Consume the pending body or flush pending output before retrying.
Sender BufferFull Yes Provide sufficient encoding workspace.
Parser syntax/framing/capacity error No Close the poisoned connection.
Transport/TLS error or cancellation No implicit reuse Poison/end the exchange and handle any delivered prefix.

Always examine the layer returning an error. A retryable sender output shortage does not make parser workspace exhaustion retryable, and an HTTP 500 response is a completed protocol response rather than a parser failure.

RedirectPolicy.defaults() disables automatic redirects. permits receives explicit source/target origins, hop count, and body replayability; HTTPS downgrade is refused by default. RetryPolicy.defaults() allows one attempt and no retry. Additional attempts require an idempotent method, replayable body, and no started response. These helpers make decisions without executing them. A convenience client must apply its configured status/method rewrite policy and supply a replayable body provider before starting another attempt.

forward_header(name, same_origin, to_proxy) strips Host for recomputation, strips authorization and cookies across origin changes, and never sends Proxy-Authorization to an origin server. Application-specific secret headers need an application allowlist. Connection pool admission requires both a fully finished sender and Parser.reusable(), an alive transport, no unread body, no pending output, matching origin/proxy/TLS identity, and nonexpired explicit idle policy. The portable parser’s reusable flag alone is insufficient.

Verification

cargo test --test http_library runs fixtures at -O0 and -O3, tests every fragment width for ordinary/chunked/informational/upgrade messages, malformed framing and truncation, pipelined input, output backpressure, resource limits, and deterministic byte-mutation progress properties. Separate compile-fail fixtures enforce parser view lifetimes. CPython’s http.client.HTTPResponse independently decodes Dodo-produced headers, chunks, trailers, and streamed body reads. The same portable fixture emits objects for wasm32-unknown-unknown and thumbv6m-none-eabi; it has no platform import.

Convenient client composition

std/http/client.Client wraps the bounded connection driver around an explicit origin and caller-owned parser/input/output buffers. The runnable examples/http_client_polling.dodo connects to the bundled web-server example with std/net/native, passes its nonblocking stream to Client methods, and streams the response without allocating or buffering the complete body.

Construct Client.new(origin, workspace, input, output, limits, policy) after establishing the provider stream. origin is an absolute Uri whose bytes remain borrowed by the client. begin(&request, now_ms, cancelled) validates origin, Host, and proxy target form and queues the request head. Use poll_flush(stream, now_ms, cancelled) until pending_output() is zero. send_body(source, now_ms, cancelled) accepts only the prefix it can stage; flush it before submitting the next fragment. finish_body(trailers, now_ms, cancelled) ends the request. poll_event(stream, now_ms, cancelled), body(), and consume_body(count, now_ms, cancelled) expose response events and bounded backpressure. next resets only a fully drained reusable exchange. An upgrade exposes unread() for explicit handoff and cannot return to HTTP reuse.

Every action receives explicit monotonically nondecreasing milliseconds and a cancellation snapshot. A backwards timestamp, cancellation, or reached deadline fails before another provider operation. The request timeout covers the whole exchange from begin, including application body processing and queued output. Defaults are 10 seconds to connect, 30 seconds per request, 30 seconds idle, and four idle slots. A zero timeout disables that deadline. Addition saturates instead of wrapping. connect_deadline(now_ms) supplies the external connector with the configured absolute deadline; the client does not execute DNS, connection establishment, waits, or TLS handshakes itself. The provider must complete or cancel its own pending operations before releasing their storage.

Backpressure is a retryable flow-control result. Protocol, transport, cancellation, deadline, and invalid sequencing failures poison the connection. A client cannot reset a failed exchange into reuse. Getters borrow buffered bytes and perform no I/O; a live body view prevents any mutation of the client. After an action is backpressured, the caller must explicitly retry it when the sink or output buffer can progress. This API does not install an executor or silently spin on Pending.

Proxy policy is mandatory and never inferred from the process environment:

Policy Required request target and provider state
Direct Origin-form path (or OPTIONS *); attached origin stream.
Forward Absolute-form URI matching the configured HTTP origin; attached proxy stream. HTTPS origins are rejected.
Tunnel Origin-form after an external CONNECT and any required TLS verification; caller explicitly calls tunnel_established().

Host must match the configured authority. Proxy-Authorization is accepted only for forward-proxy traffic and is rejected on direct or tunneled origin traffic. Client does not initiate CONNECT; use the independent connection engine to perform it, preserve upgrade bytes, finish TLS if needed, then attach a fresh client to the established provider. For HTTPS the provider must be a verified TLS stream: passing a plaintext socket under an HTTPS URI does not magically upgrade it. TLS provider selection and trust credentials remain explicit.

Redirect and retry actions are deliberately visible to the caller. Configure Policy.redirects/retries, obtain the decision, and construct the next request only from an explicitly replayable source. 303 rewrites non-HEAD methods to GET and discards the body; 301/302/307/308 preserve the method and require replayability. retry_allowed refuses replay after any response bytes have arrived and requires an idempotent method. Failed-connection retries require a fresh provider stream and client. No request is sent twice automatically.

RedirectHeaders.new(headers, same_origin, to_proxy) iterates fields permitted for a redirected head. It removes framing fields, Host, connection-specific fields (including names nominated by Connection), and credentials crossing origin boundaries. It never forwards proxy credentials to an origin. The caller supplies new Host/framing and applies an additional allowlist for custom secret headers.

Pool.new(slots) uses caller-owned slot metadata. Pool.from_policy(slots, &policy) additionally limits usable slots to max_idle. Each slot binds explicit, collision-free origin, proxy, and TLS/trust identities. take rejects mismatched identities and expires idle slots using supplied time; release requires a reusable completed connection. Check the current client deadline/cancellation before pool admission. Transport owners remain in the application’s corresponding slots and must be closed when metadata becomes vacant. There is no global pool, allocation, or implicit transport destructor in this metadata layer.

Hosted convenience layer

Use std/http/hosted for a reusable URL client and std/http/https for verified HTTPS. They drive connection, readiness, deadline, and body-transfer loops. See Hosted HTTP and HTTPS for complete small programs, explicit storage bounds, resolver/deadline scope, cancellation, and a generated local HTTPS setup. The protocol APIs on this page remain usable independently.

Complete API reference

For every public type, field, constant, and function signature, see std/http, std/http/connection, std/http/client, std/http/server.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?