Skip to content

Stdlib · tier 1

http

HTTP client and server

official_libraries.md · 401 lines · 11 min read

use http

HTTP over net’s layer 2 (byte sockets + TLS, §4/net.md), parsing the protocol itself. It does not use the typed Channel[T]: that is the language’s native RPC (both sides share T at compile time), and HTTP starts from the opposite premise, talking to the world-that-isn’t-yours, whose contract is the wire format (method, headers, status, bytes), language-agnostic. There is no T to put in the channel, so HTTP lives in the bytes. The application surface is version-agnostic (Request/Response/Headers/ Body identical in /1.1, /2, /3); the version lives in the connection, negotiated by ALPN. This package delivers the primitives (types + client + server transport + the handler shape fn(Request) -> Response); router, middleware, and application batteries live in the official web lib (tier 2), on top of this one.

The part that does not change between /1.1, /2, and /3. Method, status, headers, body, URL, and the two envelopes (Request/Response).

decl Method { Get; Head; Post; Put; Patch; Delete; Connect; Options; Trace }
fn (m: Method) to_string() -> string // Display ("GET", "POST", …)
fn (Method) parse(s: string) -> Optional[Method] // none if unknown
decl Status { pub code: u16 } // 100..599
fn (Status) of(code: u16) -> Status
fn (s: Status) reason() -> string // canonical text ("Not Found")
fn (s: Status) is_informational() -> bool // 1xx
fn (s: Status) is_success() -> bool // 2xx
fn (s: Status) is_redirect() -> bool // 3xx
fn (s: Status) is_client_error() -> bool // 4xx
fn (s: Status) is_server_error() -> bool // 5xx
fn (s: Status) to_string() -> string // Display ("404 Not Found")

Named constants for the common statuses (avoids magic numbers at the call site):

const ok: Status // 200
const created: Status // 201
const accepted: Status // 202
const no_content: Status // 204
const moved_permanently: Status // 301
const found: Status // 302
const see_other: Status // 303
const not_modified: Status // 304
const temporary_redirect: Status // 307
const permanent_redirect: Status // 308
const bad_request: Status // 400
const unauthorized: Status // 401
const forbidden: Status // 403
const not_found: Status // 404
const method_not_allowed: Status // 405
const conflict: Status // 409
const too_many_requests: Status // 429
const internal_error: Status // 500
const not_implemented: Status // 501
const bad_gateway: Status // 502
const service_unavailable: Status // 503

Headers: multi-value, case-insensitive name

Section titled “Headers: multi-value, case-insensitive name”

Map[string,string] does not work: an HTTP header has a case-insensitive name and is multi-value (several Set-Cookie, several Accept). Hence the dedicated type:

decl Headers { ... }
fn (Headers) new() -> Headers
fn (h: *Headers) set(name: string, value: string) // replaces ALL values for the name
fn (h: *Headers) add(name: string, value: string) // appends (preserves the previous ones)
fn (h: *Headers) remove(name: string)
fn (h: Headers) get(name: string) -> Optional[string] // 1st value (case-insensitive lookup)
fn (h: Headers) get_all(name: string) -> List[string] // all values for the name
fn (h: Headers) has(name: string) -> bool
fn (h: Headers) names() -> Iterator[string] // names present (canonical form)
fn (h: Headers) iter() -> Iterator[(string, string)] // each (name, value) pair
fn (h: Headers) len() -> usize

Lookup is case-insensitive (h.get("content-type") finds Content-Type); the wire output uses the header’s canonical capitalization.

Parsed URL (the client is the consumer; if another domain needs it, it gets promoted to net later):

decl Url {
pub scheme: string // "http" / "https"
pub host: string
pub port: Optional[u16] // none → scheme default (80/443)
pub path: string // "/" when empty
pub query: Query
pub fragment: Optional[string]
}
fn (Url) parse(s: string) -> Result[Url, error{Malformed}]
fn (u: Url) to_string() -> string // Display (re-encodes percent-encoding)
fn (u: Url) with_path(path: string) -> Url
fn (u: Url) with_query(q: Query) -> Url
decl Query { ... } // multi-value and ORDERED (the wire order matters)
fn (Query) new() -> Query
fn (Query) parse(s: string) -> Query // "a=1&b=2" → Query (decodes percent-encoding)
fn (q: *Query) add(key: string, value: string)
fn (q: *Query) set(key: string, value: string)
fn (q: Query) get(key: string) -> Optional[string]
fn (q: Query) get_all(key: string) -> List[string]
fn (q: Query) iter() -> Iterator[(string, string)]
fn (q: Query) to_string() -> string // "a=1&b=2" (encodes)

The body satisfies Readable + Closeable (io): the default is a stream (it does not load everything into memory, which serves large downloads and SSE), with eager conveniences on top.

decl Body { ... } // Readable + Closeable
fn (Body) empty() -> Body
fn (Body) from_bytes(data: []byte) -> Body // known body (Content-Length)
fn (Body) from_string(s: string) -> Body
fn (Body) from_reader(r: Readable) -> Body // stream of unknown size → chunked
fn (b: *Body) read(buf: mut []byte) -> Result[usize, error{Io, Closed}] // Readable (Ok(0)=end)
fn (b: *Body) close() -> error{Io} // Closeable
// conveniences (consume the body; allocate via the context's @mm):
fn (b: *Body) read_all() -> Result[[]byte, error{Io, Closed}]
fn (b: *Body) to_string() -> Result[string, error{Io, Closed, InvalidUtf8}] // validates UTF-8 at the edge
fn (b: Body) len_hint() -> Optional[usize] // Content-Length if known; none if chunked
decl Request {
pub method: Method
pub url: Url
pub headers: Headers
pub body: Body
pub version: Version // on the server, the negotiated version; on the client, the transport decides
}
fn (Request) build(method: Method, url: Url) -> Request // explicit form
fn (Request) get(url: string) -> Result[Request, error{Malformed}] // conveniences (parse the URL)
fn (Request) post(url: string, body: Body) -> Result[Request, error{Malformed}]
fn (r: *Request) with_header(name: string, value: string) -> Request // chainable (returns the adjusted one)
fn (r: *Request) with_body(body: Body) -> Request
decl Response {
pub status: Status
pub headers: Headers
pub body: Body
pub version: Version
}
fn (Response) of(status: Status) -> Response // empty response with status
fn (Response) text(status: Status, s: string) -> Response // text body + Content-Type text/plain
fn (Response) bytes(status: Status, data: []byte, content_type: string) -> Response
fn (Response) stream(status: Status, r: Readable, content_type: string) -> Response // streamed body
fn (r: *Response) with_header(name: string, value: string) -> Response
fn (r: *Response) with_body(body: Body) -> Response
decl Version { Http11; Http2; Http3 }
fn (v: Version) to_string() -> string // "HTTP/1.1" / "HTTP/2" / "HTTP/3"

Full by design (pool, redirect, cookie jar, decompression), because an incomplete HTTP client does not serve real systems. Everything happens inside send; the Client carries the state (connection pool, cookie jar).

decl Client { ... }
fn (Client) new() -> Client // sensible defaults (pool/redirect/cookies/gzip on)
fn (Client) with(cfg: ClientConfig) -> Client
fn (c: *Client) send(req: Request) -> Result[Response, HttpError] // the central method
fn (c: *Client) get(url: string) -> Result[Response, HttpError] // conveniences
fn (c: *Client) post(url: string, body: Body) -> Result[Response, HttpError]
fn (c: *Client) put(url: string, body: Body) -> Result[Response, HttpError]
fn (c: *Client) delete(url: string) -> Result[Response, HttpError]

Configuration and the client’s closed error-set:

decl ClientConfig {
pub timeout: Duration // per request, end to end (default 30s)
pub max_redirects: u32 // 0 = do not follow (default 10)
pub pool: PoolConfig
pub cookies: bool // automatic cookie jar (default true)
pub auto_decompress: bool // transparent gzip/deflate on the response (default true)
pub tls: TlsConfig // from net: verify, sni, alpn, ca_bundle
pub default_headers: Headers // merged into every request (User-Agent, Accept, …)
pub https_redirect_only: bool // refuses a redirect that downgrades https→http (default true)
}
decl PoolConfig {
pub max_idle_per_host: u32 // reusable idle connections per host (default 8)
pub max_per_host: u32 // cap on simultaneous connections per host (0 = unlimited)
pub idle_timeout: Duration // closes the idle one after (default 90s)
}
alias HttpError = error{
Malformed, // malformed URL or response
Dns, // name resolution failed
Connect, // did not connect (refused / unreachable)
Tls, // handshake / certificate
Timeout, // blew the request timeout
TooManyRedirects, // exceeded max_redirects
Closed, // the connection closed midway
Io
}
use http
client := http.Client.new()
resp := client.get("https://api.exemplo.com/users") catch |e| { return e }
if resp.status.is_success() {
body := resp.body.to_string() catch |e| { return e }
process(body)
}

Large download without loading everything into memory: the body is Readable, so io.copy pumps it.

resp := client.get("https://exemplo.com/grande.bin") catch |e| { return e }
defer resp.body.close() // returns the connection to the pool
out := fs.create("grande.bin") catch |e| { return e }
io.copy(out, resp.body) catch |e| { return e } // stream → file, in blocks

Connection and pool lifecycle: send reuses an idle connection from the pool for the same (scheme, host, port) or opens a new one; the connection only returns to the pool when the response body is drained or closed, which is why you should read the body to the end (read_all/to_string) or defer resp.body.close(). Redirects are followed up to max_redirects, with HTTP semantics (303 becomes GET; 307/308 preserve method and body); a streamed request body is not replayable, so a redirect that requires resending it becomes Err (use an eager body, from_bytes/from_string, when you need to survive a redirect). Cookie jar and decompression are automatic when enabled.

Cookie jar (automatic; exposed for inspection/control):

fn (c: *Client) jar() -> *CookieJar
decl CookieJar { ... }
fn (j: *CookieJar) add(url: Url, c: Cookie)
fn (j: *CookieJar) cookies_for(url: Url) -> List[Cookie]
fn (j: *CookieJar) clear()
decl Cookie {
pub name: string
pub value: string
pub domain: string
pub path: string
pub expires: Optional[DateTime] // from `time`; none = session
pub secure: bool
pub http_only: bool
}

The handler is just a function fn(Request) -> Response, with no new type:

alias Handler = fn(Request) -> Response
fn serve(addr: string, handler: Handler) -> error{AddrInUse, PermissionDenied, Io} // brings up and blocks
fn serve_with(cfg: ServerConfig, handler: Handler) -> error{AddrInUse, PermissionDenied, Io}
// controllable form (graceful shutdown):
fn bind(cfg: ServerConfig, handler: Handler) -> Result[Server, error{AddrInUse, PermissionDenied}]
decl Server { pub addr: Address }
fn (s: *Server) run() -> error{Io} // serves until shutdown (suspends this process)
fn (s: *Server) shutdown(grace: Duration) -> error{Io} // stops accepting; drains within 'grace'; closes
decl ServerConfig {
pub addr: string
pub tls: Optional[TlsConfig] // none = plain text (http/1.1, h2c); some = HTTPS (h2/h3 via ALPN)
pub versions: List[Version] // what to offer (default: everything the transport supports)
pub read_timeout: Duration // per request
pub write_timeout: Duration
pub max_header_bytes: usize // cap on headers (anti-abuse)
pub max_body_bytes: Optional[usize] // cap on body (none = no cap)
}

The model is the actor one (§3/§8) extended to HTTP. In /1.1, each connection is a serial stream, and the server does a spawn of a process running handler(req). In /2 and /3, the connection is multiplexed: an owner process demultiplexes the frames (HPACK/QPACK, flow control, stream state) and does a spawn of one process per stream. In any version, the handler runs in its own process: a panic on one request crashes only that process (the supervisor observes it through catch, §8), never the server, and the client receives a 500. The response can have a streamed body (Response.stream) for SSE and streaming, without materializing everything.

use http
fn handle(req: Request) -> Response {
match req.method {
Get => Response.text(http.ok, "olá, {{req.url.path}}")
_ => Response.of(http.method_not_allowed)
}
}
fn main() {
http.serve("0.0.0.0:8080", handle) catch |e| {
io.eprintln("falha ao subir: {{e}}")
os.exit(1)
}
}

Graceful shutdown pairs with signals-as-a-channel (os.signals, §4): run the server in a process and ask for shutdown when the signal arrives.

srv := http.bind(cfg, handle) catch |e| { os.exit(1) }
spawn srv.run()
sigs := os.signals(.Interrupt, .Terminate)
loop s in sigs {
match s {
Interrupt | Terminate => { srv.shutdown(10s); break }
_ => {}
}
}

The surface above is identical across the three versions; the difference lives here, and each version sits on net (layer 2) in its own way. The version is negotiated, not chosen per call.

  • HTTP/1.1, over TcpStream (or TlsStream), §net. One request/response at a time on the connection; keep-alive reuses the connection serially; a body of unknown size goes chunked. The pool keeps idle connections (many connections per host for concurrency). It is the complete cut of the first release.
  • HTTP/2, over a single TlsStream (ALPN negotiates "h2") or plain text h2c. One connection multiplexes many streams: the owner process runs the frame layer (HPACK, flow control) and does a spawn per stream. This refines the actor model, process-per-stream instead of process-per-connection, without breaking it; and the pool now keeps one connection per host with many concurrent streams (instead of the many connections of /1.1).
  • HTTP/3, over QUIC (UDP, via net’s udp_bind/datagrams), with TLS 1.3 built into the transport, its own multiplexing (no TCP head-of-line blocking) and 0-RTT. ALPN "h3" + Alt-Svc announces availability. The application surface does not change; what is big is the implementation: a QUIC stack (congestion control, loss recovery, multiplexing over UDP) is an entire subsystem, delivered as the lib matures. The commitment is locked in by the already version-agnostic API; the weight is the stack.

How the version is decided. On the client, ALPN during TLS negotiates the highest mutually supported version (h2/h3 if offered, otherwise http/1.1); h3 additionally depends on discovery via Alt-Svc or opt-in. On the server, ServerConfig.versions lists what is offered, and ALPN selects per connection. There is no per-request version button: the surface is agnostic, the connection resolves it. (Forcing a specific version, for testing, is a future direction via ClientConfig.)


  • Tier 1, over net’s layer 2. HTTP talks to the world-that-isn’t-yours via bytes + TLS, not through the typed Channel[T]. The typed channel is the native RPC (both sides share T); HTTP is language-agnostic, there is no T. It is a category difference, not a limitation, and that is why the lib parses the protocol itself over TcpStream/TlsStream/QUIC.
  • Version-agnostic surface (like Go’s net/http): Request/Response/Headers/Body identical in /1.1, /2, /3; the version lives in the connection, negotiated by ALPN. No version per call.
  • Process per stream, not per connection. Actor model (§3/§8) extended: /1.1 uses process per connection; /2-3 uses process per multiplexed stream. A panic on one request crashes only its process; the server goes on. It is the language’s fault tolerance applied to HTTP, for free.
  • The framework is web (tier 2), separate. http is primitive (types + client + server transport + the fn(Request) -> Response shape); router, middleware, and app batteries go up in web, and the UI (§20) goes up in web. Whoever imports http wants to build; whoever wants batteries imports web. That is why HTTP ended up non-core: it is, itself, a layered stack.
  • Full client (pool/redirect/cookie/decompression) because it aims at services/systems, where an incomplete client is useless. Everything inside Client.send; the state (pool, jar) lives in the Client.
  • Streaming body by default: Body satisfies Readable+Closeable; read_all/to_string are conveniences. Large download and SSE without materializing everything.
  • Colorless async (§12): no function is async; the process suspends on IO; parallelism is spawn per stream. No Future/await/context.Context; cancellation is timeout or process death (§8).
  • /3 committed, implementation deferred. The already-agnostic surface lets QUIC enter without trauma; the QUIC stack is the real weight, delivered as the lib matures.
  • Decompression uses codecs from encoding. auto_decompress leans on DEFLATE/gzip (frozen formats, candidates for encoding/compress as brotli/zstd come in); http only orchestrates them, the same role that net/TLS plays with crypto. The lib does not reimplement compression.
  • No allocator in the signatures (colorless); streaming buffers allocate via the context’s @mm, or you pass mut []byte where you want control (the read(buf) port of Readable).
  • Url lives here (the consumer is the client); it gets promoted to net if another domain comes to need it.