Standard library Dodo 0.1.4

Hosted HTTP and HTTPS

Small bounded clients and serial web servers with library-owned I/O loops.

On this page

This guide connects the HTTP pieces into complete programs. Use std/http/hosted for an HTTP client, std/http/https for an HTTP/HTTPS client with verified TLS, and std/web/app for a configured server. std/web/https adds a certificate/key identity to that server.

The hosted layer runs resolution, connection, readiness, framing, body transfer, and cleanup loops for you. You still choose storage limits, timeouts, trust, and how to handle results. It supports Linux GNU x86-64 and Windows x64 MSVC/GNU; portable protocol APIs are documented in HTTP and application behavior in Web applications.

Plan the request

Before constructing a client, choose these independent limits:

  1. Protocol workspace: use hosted.WORKSPACE_BYTES for parser, I/O, and retained response headers. The client borrows it until dropped.
  2. Response destination: use get(url, &mut buffer) for a bounded in-memory result, or get_to(url, &mut writer) to process bytes as they arrive.
  3. Body policy: config.body_bytes caps decoded bytes even with a streaming writer. A smaller collection buffer adds its own tighter limit.
  4. Time policy: resolution, connection, and request budgets have distinct scopes. See timeouts.
  5. Trust: for HTTPS, choose explicit PEM roots or OpenSSL’s configured roots.

Start with a local server so you can distinguish protocol or program errors from external DNS, trust, and connectivity problems.

Quickstart

This GET streams to the existing console writer. Non-2xx statuses are valid HTTP responses; the application decides whether they are successful. Save it as get_example.dodo. hosted.WORKSPACE_BYTES is fixed caller-owned protocol storage; console chooses the body sink and hosting names the error type. The hosted APIs support Linux GNU x86-64 and Windows x64 MSVC/GNU.

package get_example
import "std/console"
import "std/http/hosted"
import "std/http/hosting"

fn run() -> i32!hosting.Error {
    workspace := [0u8; hosted.WORKSPACE_BYTES]
    client := hosted.Client.new(&mut workspace, hosted.Config.defaults())?
    output := console.stdout()
    response := client.get_to(b"http://localhost:8080/", &mut output)?
    if response.status != 200 { return ok(2) }
    return ok(0)
}
fn main() -> i32 {
    match run() {
        ok(code) => { return code }
        err(reason) => {
            errors := console.stderr()
            match errors.println(&reason) { ok(_) => {} err(_) => {} }
            return 1
        }
    }
}

Save this companion as server_example.dodo; it needs no files, credentials, external libraries beyond the target C runtime, or Internet service. The route and handler live in a server object with bounded default protocol and body storage:

package server_example
import "std/web"
import "std/web/app"

fn main() -> i32 {
    return app.new()
        .get(b"/", web.text(b"Hello, Dodo!\n"))
        .run(b"127.0.0.1:8080")
}

In terminal 1, run the copied server:

dodo run server_example.dodo

It waits silently. In terminal 2, run the copied client:

dodo run get_example.dodo

Expected client stdout is Hello, Dodo! followed by a newline, with exit 0. The server keeps accepting until stopped with Ctrl+C. If the client races server compilation and reports connection refused, retry when the server is running. If bind fails, check for another listener or a recently closed port; use a fresh port in both examples if necessary. A failed request exits 1 and prints its error on stderr; check the cause and delivered-prefix count before retrying. A completed non-200 response exits 2. The server reports individual failed connections in its Report, independently of startup errors.

For the shortest bounded-body and one-request examples, see HTTP and web serving; those guides also cover the underlying protocol APIs. Config.max_connections sets a finite number of accepted connections (including rejected requests). Repository versions are http_client.dodo and web_server.dodo. The previous incremental programs remain as http_client_polling.dodo and web_server_polling.dodo.

Client API and storage

API Purpose
hosted.Client.new(workspace, config) Reusable context borrowing explicit storage
client.get(url, storage) Collect decoded body bytes into bounded caller storage
client.get_to(url, writer) Stream to any ordinary std/io writer
client.request(&request, writer) General method, headers, and byte-slice request body
client.header(name, occurrence) Borrow a retained final response header; duplicates are preserved
client.request_with(request, writer, resolver, connector, clock, cancel) Inject execution capabilities
https.Client.new(workspace, config, trust) Same convenience API, accepting HTTP and HTTPS
https.Client.request_with(request, writer, resolver, clock, cancel) Inject resolution/cancellation while keeping verified TLS

hosted.Request has url, method, headers: &[http.Header], and body: &[u8]. For example, a general request inside a function returning !hosting.Error:

headers := [http.Header { name: b"Content-Type", value: b"text/plain" }]
request := hosted.Request {
    url: b"http://localhost:8080/echo", method: b"POST",
    headers: &headers, body: b"hello"
}
body := [0u8; 1024]
writer := io.MemoryWriter.new(&mut body)
response := client.request(&request, &mut writer)?

Import std/http and std/io for this fragment. Response contains status, body_bytes, and redirects. For collection, storage[..response.body_bytes as usize] is the initialized body. hosting.Error.delivered counts the sink’s accepted prefix on failure; it is not an acknowledgement by a downstream device. An overfull collection fails BodyLimit, retaining its accepted prefix. A failed request clears retained headers. Successful header views last until the next request and keep the client borrowed. Informational heads and trailers are parsed and validated but are not returned as final headers.

The client requires 53,248 workspace bytes: 16 KiB parser, 4 KiB input, 16 KiB output, and 16 KiB retained headers. It never grows them. Per-call stack scratch also includes two 4 KiB URL buffers, a 4 KiB target, 16 header slots, and 384 resolver bytes (at most 16 addresses). Ordinary stack usage and platform resolver/socket allocations are separate from workspace capacity. HTTPS adds two 4 KiB staging buffers and OpenSSL allocations. Context reuse saves workspace; each exchange uses a fresh connection, closed on all normal/error exits.

Defaults and hard limits:

Setting Default / cap
header_bytes, header_fields 16,384 / 100; configurable downward
Response body_bytes 16 MiB; configurable, decoded bytes
request_body_bytes 16 MiB; configurable
URL / hostname 4,096 / 253 bytes
Caller request headers 14, plus generated Host and Connection
Informational responses 16 per exchange
Chunk line / trailers 1,024 bytes / 4,096 bytes and 32 fields
max_redirects 0; configurable through 16
Automatic failed-exchange retries 0

Absolute ASCII http:// and https:// URLs support DNS A-labels, IPv4, bracketed IPv6, explicit ports, empty paths, and queries. Userinfo, fragments, scoped literals, percent-encoded hostnames, Unicode hostnames, and port zero are rejected. Host includes the original authority, including IPv6 brackets and an explicit port. Origin-form request targets omit the authority. Content-Length is generated from the byte-slice body, including zero. Caller Host/framing, Connection, Expect, Upgrade, Trailer, TE, and proxy-authorization fields are rejected before sending. CONNECT and protocol upgrades are unsupported.

Redirects are off by default; 3xx responses then return normally, including their bodies. When enabled, only absolute URLs and origin-relative /path locations within the same origin are followed. Cross-origin redirects, HTTPS downgrades, missing/unsupported locations, and hop exhaustion fail Redirect. Intermediate bodies are closed without delivery. The portable redirect policy determines method handling: 301/302/307/308 preserve method/body; 303 becomes GET except for HEAD. Every supplied body is a replayable byte slice. No streaming source is rewound and no failed exchange is retried, even for GET. Address fallback occurs only before any HTTP request bytes are sent. There are no ambient proxies, cookies, decompression, connection pools, or credential forwarding across origins.

Handle status, errors, and partial output

ok(response) means a complete response reached your sink, even if its status is 404 or 500. err(reason) means the operation did not complete. Handle the outer Result before examining HTTP status. A sink can already have received bytes before a later framing, deadline, or transport failure.

hosting.ErrorKind Relevant details Decision
InvalidInput, Workspace Configuration, URL, or storage is invalid. Correct the input/capacity before another request.
Resolve, Connect reason.network Check the host, address, service, and network policy.
Transport reason.transport Preserve delivered; investigate the I/O source or sink.
Protocol, HeaderLimit reason.protocol Inspect malformed framing or configured bounds.
Tls reason.security Check trust, peer identity, and the TLS failure contract.
BodyLimit reason.delivered Increase a deliberate bound or choose another sink strategy.
TimedOut, Cancelled reason.delivered The connection is closed; application retry is a new exchange.
Redirect Redirect policy refused another hop. Inspect policy and destination rather than silently following.

Only inspect a nested cause when its error kind makes that cause relevant; other fields may contain placeholder values. delivered counts bytes accepted by the selected sink. A file sink can contain a partial file; a console sink may already have displayed a prefix. Replaying a request does not erase those side effects, and repeating a non-idempotent method may repeat server work.

The default client performs no automatic failed-exchange retry. Reusing the client reuses its workspace, while each request creates a fresh connection.

Precisely scoped timeouts and cancellation

Client configuration uses nonzero millisecond durations:

  • resolution_timeout_ms (10,000) is a separate budget passed to the resolver. It is checked immediately before and after resolution.
  • request_timeout_ms (30,000) starts after successful resolution, separately for each redirect hop. It covers TCP connect, TLS construction/handshake, request writes, response headers/body, sink backpressure, and TLS close-notify delivery. It does not reset on progress.
  • connect_timeout_ms (10,000) caps TCP address attempts together and cannot extend the request deadline. TLS runs within the remaining request budget.

The platform resolver is synchronous and cannot be cancelled mid-call. A resolution timeout/cancellation is reported after that call returns. A hung platform resolver can therefore keep the hosted call blocked indefinitely. These settings are not an enforceable end-to-end URL deadline. A redirect has another resolution call and another network budget. The platform resolver may allocate and returns at most the first 16 addresses; additional addresses are not attempted.

Inject a resolver with this structural method:

resolve<C, X>(&mut self, host: &[u8], port: u16, workspace: &mut[u8],
    operation: &net.Operation, clock: &mut C, cancel: &X)
    -> Addresses!net.Error from(workspace)

Its result implements len() and get(index) -> net.SocketAddress!net.Error. It may use its own result type and borrowed workspace. Return at most 16 addresses and retain no background work, input borrow, or operation after return. For bounded/cancellable resolution, the provider must honor the supplied operation during its own I/O. The client also checks that operation after return. For example a numeric/cached resolver can use native.resolve_numeric; the integration fixture includes a deliberately blocking injected resolver proving the default scope. A custom connector implements the public PlainConnector contract; https.Connector supplies the verified implementation.

clock.now_ms() must be monotonic. cancel.cancelled() is checked before I/O and waits; cancellation wins over timeout. Native readiness waits are at most 10 ms, and writer backpressure uses 1 ms sleeps. Deadlines are inclusive; OS scheduling can delay observations. Ordinary synchronous writers, application handlers, resolver implementations, and backend construction calls cannot be preempted by these loops. Checks occur between calls. A blocking console/file writer can thus exceed a deadline too; use a nonblocking writer when bounded cancellation is required. Zero timeout durations are rejected.

Serial server, handlers, and shutdown

The buffered serving lifecycle is: accept, parse the complete bounded request, decode and select a route, run the handler, serialize the response, and clean up the connection. The serial runner handles one connection through that whole sequence before accepting the next. Concurrent execution lets other sockets advance while one waits, but handlers still execute synchronously on the serving thread.

Choose the web guide for routing, middleware, and application tests. The details below explain the lower-level runner’s capacities and failure behavior when you supply custom storage or execution providers.

Start with app.Server for automatic route registration, grouped configuration, bounded default buffers, and explicit serial or concurrent execution. The lower-level web/hosted.serve(address, routes, handler, workspace, request_body, response_body, config) parses a numeric bind address, validates the router, binds one listener, and accepts repeated connections. It handles one request per connection and sends Connection: close. There is no per-peer thread, queue allocation, keep-alive pipeline, or unbounded task creation. The OS listen backlog defaults to 256. A slow client occupies the single execution lane until its limit, deadline, or cancellation fires.

The serial, HTTPS and concurrent servers share the buffered handler interface:

pub fn handle(&mut self, request: &mut web.Request,
    response: &mut web.Response) -> void!web.Failure

The request exposes method, decoded path, headers, query pairs, route parameters and the complete decoded body. The response copies status, headers and text, HTML or byte bodies into caller-owned storage. hosted.Text.new(bytes) remains a complete constant-text handler. See request and response contracts for duplicate handling, decoding, checked borrowing, middleware and examples. Streaming request callbacks (head/poll_write/complete), response production and upgrades remain available through the portable application/connection APIs. std/web/reactor provides concurrent socket progress and connection reuse with the same buffered handler.

The fixed server workspace is 61,440 bytes: 16 KiB parser, 4 KiB input, 16 KiB output, 2 KiB decoded path, 2 KiB decoded query pairs, 16 KiB retained request headers and 4 KiB copied response headers. Each retained/copied pair uses its name and value length plus two delimiter bytes. Protocol wire-head bytes are bounded separately. Per-call stack storage includes a 2,048-entry route index and 33 temporary response header views (32 application fields plus Connection). Tables above 1,024 routes use a separately owned index with two usize slots per route, allocated once before binding and freed when serving returns. Both runners retain indexing for these larger tables. The reactor requires protocol workspace per slot. Request and response buffers never grow.

Config setting Default / hard cap
header_bytes, header_fields 16,384 / 100; configurable downward
path_bytes 2,048; configurable downward, nonzero
query_bytes, query_fields 2,048 / 100; configurable downward, including zero
response_header_bytes, response_header_fields 4,096 / 32; configurable downward, including zero
body_bytes, response_bytes 65,536 each in hosted.Config (4,096 in app.Config); effective cap is also bounded by the supplied body arrays

Header field limits include application-added defaults such as Content-Type; the generated Connection and Content-Length do not consume application header storage. Zero query fields permits no nonempty query components; zero response header capacity still permits byte responses without application headers. Each body buffer is separate from the workspace. Serialized response heads fit within the 16 KiB output slice even at the maximum response field/byte limits.

Overlarge request bodies get 413, oversized heads 431, path/query capacity exhaustion 414, malformed framing/path/query 400, absent routes 404 and wrong methods 405. Handler errors (including invalid headers/status, invalid text and response capacity exhaustion) get 500. A failed handler’s partial response is never sent. HEAD and 204/205/304 suppress body bytes with the framing rules in the web guide. Valid Expect: 100-continue is answered after validating the request target and route, before reading the body. After final output starts, a failure closes the connection without another status line.

hosting.Error.application retains a buffered API failure and its underlying protocol/text diagnostic. Report.last_error retains the latest observed connection failure; the counters still distinguish completed, rejected and failed connections. serve_connection returns errors after sending a public rejection, with responded set when that write completed. The ordinary server counts these as rejected and continues accepting.

Server timeouts are nonzero and absolute within their scopes:

  • header_timeout_ms (10,000) starts at accepted plaintext request processing.
  • body_timeout_ms (30,000) starts when headers complete.
  • request_timeout_ms (30,000) spans plaintext headers, body, handler, and output, capping the phase deadlines without resetting on progress.
  • write_timeout_ms (30,000) caps response output. An input timeout may generate 408 with a new bounded error-write interval; total connection time may thus include that extra interval. Cancellation skips error writing.
  • HTTPS handshakes have a separate header_timeout_ms budget before plaintext processing. Successful TLS shutdown has a separate write budget and delivers our close_notify without waiting indefinitely for the peer’s notification.

serve_with(..., config, acceptor, clock, cancel) exposes cooperative shutdown; use PlainAcceptor or https.Acceptor. Any provider with cancelled(&self) -> bool works, including a shared atomic provider from std/sync/allocated for another thread or a clock-based provider. It stops acceptance and aborts the active exchange. An already-running synchronous handler must return before shutdown is observed. All sockets/listener and TLS engines drop before return. Report gives accepted, completed, rejected, failed, and cancelled counts; client protocol failures do not terminate the listener. max_connections is an additional explicit finite shutdown condition. The integration server fixture demonstrates deadline-triggered cancellation while a peer is holding an incomplete request.

Complete local HTTPS setup

The HTTPS server uses the same fluent app API:

package https_server
import "std/web"
import "std/web/app"
import "std/web/https"

fn main() -> i32 {
    return app.new()
        .get(b"/", web.text(b"Hello, Dodo!\n"))
        .concurrent()
        .run_with(b"127.0.0.1:8443", https.files("cert.pem", "key.pem"))
}

https.files loads each PEM file once into bounded storage (8,192 bytes per file), validates the identity before binding, and supplies default HTTP buffers. It reports file paths and underlying errors on startup failure. Routes, middleware, limits, and timeouts are configured on the ordinary app builder. Add .concurrent() to enable concurrent TLS handshakes, HTTP/1.1 keep-alive, and pipelining. See HTTPS web applications for custom storage, deadlines, and cancellation.

The HTTPS import selects the existing OpenSSL backend and requires OpenSSL 3.5+ development headers and libraries, plus a target C toolchain. Hosted linking adds libssl/libcrypto. Matching dynamic libraries/provider modules must be available at runtime. This is also required if an HTTPS-enabled program only uses an HTTP URL. Plain http/hosted does not link OpenSSL.

In the client module std/http/https, https.Trust.system() uses OpenSSL’s configured default paths; on Windows this does not import the Windows certificate store. https.Trust.pem_roots(pem) uses only explicit PEM roots. A Trust value can enable both modes. Every HTTPS connection verifies the chain, validity, purpose, and URL hostname/IP SAN and uses DNS SNI. ALPN offers only HTTP/1.1; no ALPN is accepted as HTTP/1.1. There is no insecure verification switch. See TLS for backend bounds and revocation limitations. OpenSSL internal heap use is not a hard-bounded arena; its allocation failures remain typed errors.

From the repository root, create a new example directory and fresh local credentials. These commands use Python 3, OpenSSL and Dodo on PATH:

python3 scripts/local_https.py build/local-https
cd build/local-https
dodo build https_server.dodo -o server.exe
dodo build https_client.dodo -o client.exe
./server.exe
# In another terminal, from the same directory:
./client.exe
# Independent client with explicit trust:
curl --cacert ca.pem https://localhost:8443/

Both clients print Hello, Dodo! followed by a newline and exit 0. A connection or verification failure exits nonzero; check the listener, explicit trust file, certificate identity and expiry. Stop the server with Ctrl+C before removing the generated directory, including its test private keys. The optional curl command requires curl; the Dodo client has no curl dependency.

The script runs reproducible OpenSSL commands to generate a fresh local CA and one-day DNS-only localhost leaf, saves private keys with restricted permissions, and copies the complete examples/https_client.dodo and https_server.dodo programs. Credential bytes intentionally vary on every run. The server uses app.new().get(...).run_with(..., https.files(...)); the client reads its bounded trust file at runtime and verifies the server. The lower-level https.serve and explicit openssl.Config.server(certificate, key) remain available for custom identities. Use localhost, not 127.0.0.1, with this DNS-only certificate. Re-run the generator into a new directory after expiry. Stop the demonstration listener with Ctrl-C; applications can use https.files(...).serve_until(server, address, &cancel) for cooperative shutdown.

Validation and platform limits

python3 scripts/test_hosted_http.py (also run by cargo test --test http_hosted_library) generates local credentials, compiles at O0 and O3, and checks independent Python HTTP/TLS peers. It covers informational responses, fragmented chunk framing/trailers, partial writes and backpressure, bounded collection, malformed framing/EOF, body/header/time limits, redirect method and body handling, chain/hostname rejection, repeated requests, cancellation, and socket cleanup. The existing HTTP/web, TLS, and Windows harnesses continue to exercise the lower layers. --skip-tls is an explicit reduced test selection. python3 scripts/test_web_https.py additionally checks fluent routes, middleware, credential bounds and diagnostics, custom storage, and cancellation at O0/O3; it also runs through cargo test --test http_hosted_library.

Supported hosted adapters are x86-64 Linux GNU and Windows x64 MSVC/GNU. Linux musl/x32, AArch64 Linux, macOS and freestanding ABIs are unsupported by these hosted adapters. Native Windows TLS uses OpenSSL; Wine checks cannot prove installed trust paths or native entropy and provider behavior. These conveniences are HTTP/1.1 only. Platform DNS and application callbacks have the blocking limitations above. The existing native listener does not enable address reuse: after closing active connections, TCP TIME_WAIT can prevent immediate rebinding of the same port even though all listener/connection descriptors were released.

Complete API reference

For every public type, field, constant, and function signature, see std/http/hosted, std/http/https, std/http/hosting, std/web/hosted.

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
?