SCUA

How-to

Fetch data over HTTP

Calling an API or fetching a URL needs the net capability. Like file access, it's off by default: the http module doesn't exist unless the host grants net, so network access can't appear in a script — or leak into an embedded build — unless someone deliberately turns it on. And when it's on, the host says which hosts the script may reach.

#Granting the capability

From the CLI:

scua --allow-net script.scua                       # any host
scua --allow-net=api.example.com script.scua       # only this host
scua --allow-net=api.example.com,cdn.example.com script.scua   # an allowlist

Without --allow-net, import http is a compile error, before a line runs:

$ scua script.scua
script.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI (a program that only serves needs `--allow-serve` alone), or the host's capability API when embedding

The CLI's HTTP client is built on Zig's standard library, with its own pure-Zig TLS — so HTTPS works out of the box, with no system curl or OpenSSL to install.

#Making requests

import http
import json

match http.get("https://api.example.com/status")
  Ok(resp) -> print(`got {resp.status}, {len(resp.body)} bytes`)
  Error(why) -> print(`request failed: {why}`)
end

resp.body is bytes, not text, because a response can be an image or a zip as easily as JSON — and nothing about the request tells the runtime which. That has one consequence worth knowing before it bites you: you cannot put it straight into a {…} interpolation or hand it to a str.* function. Either decode it, which takes bytes directly:

let data = json.decode(resp.body)?

or convert it to text, which validates the UTF-8 and so returns a Result:

let text = bytes.to_string(resp.body)?
  • http.request(url, opts?) → Ok({ status, headers, body }) or Error({ kind, message }). opts takes method (default "GET"), headers, body, and max_bytes (the decoded-response cap, 8 MiB by default).
  • http.get(url, opts?) and http.post(url, body, opts?) are the same thing with the method fixed.

The response body is bytes, not a string — a response can be an image as easily as JSON. json.decode takes bytes directly, and bytes.to_string converts when you want text.

#Headers

Pass them as a table. Names are lowercased on the way out and on the way in, so read resp.headers["content-type"] whatever case the server used, and duplicate response headers arrive comma-joined:

let resp = http.post("https://api.example.com/v1/messages", payload, {
  headers = { "x-api-key" = key, "anthropic-version" = "2023-06-01" },
})?
print(resp.headers["retry-after"])

A few names are the client's to set, and it refuses them rather than sending a duplicate or a lie: host, connection, content-length, transfer-encoding, accept-encoding, content-encoding, te, upgrade, and expect. A request with a body and no content-type is sent as application/json.

#Errors you can branch on

Error carries a kind — a stable token — and a human message. That's what lets a retry policy retry the right things: a DNS blip is worth another go, a rejected certificate never is.

match http.get(url)
  Ok(resp) -> use(resp)
  Error(e) when e.kind == "dns" or e.kind == "connect" or e.kind == "timeout" -> retry_later()
  Error(e) -> give_up(e.message)
end

The kinds: not_allowed, bad_url, bad_header, bad_method, dns, connect, tls, tls_untrusted, timeout, too_large, protocol, unsupported, interrupted. Match on kind; print message.

#Deadlines

Every request has one, covering the whole exchange — connecting, the TLS handshake, sending, and reading the response. It defaults to 120 seconds:

let resp = http.get(url, { timeout_ms = 5s })?      -- a health check should give up quickly
let slow = http.post(url, payload, { timeout_ms = 5min })?   -- a long completion should not

An expired deadline is Error({ kind = "timeout" }), and the connection is closed rather than left running.

Whoever runs the script can set a bound for the whole run:

scua --allow-net=api.example.com --http-timeout=30000 script.scua

A per-call timeout_ms may tighten that bound but never loosen it — if the run says 30 s and the script asks for 5 minutes, it gets 30 s. That way an operator can bound someone else's script without editing it. timeout_ms = 0 asks for no deadline, which the run-wide bound still constrains if one is set.

#Reading a response as it arrives

http.get and friends wait for the whole body. For Server-Sent Events, NDJSON, or any long response you want to process while it streams, http.open stops at the response head and hands you the body as a stream:

let o = http.open(url, { method = "POST", body = payload,
                         headers = { accept = "text/event-stream" } })?
if o.status != 200 then
  http.close(o.stream)
  return Error(`server said {o.status}`)
end

while true do
  match http.read(o.stream, 8192)
    Ok(chunk) -> do
      if len(chunk) == 0 then break end   -- empty bytes: the body ended cleanly
      feed(chunk)
    end
    Error(e) -> break                     -- a failed read closes the stream for you
  end
end
http.close(o.stream)
  • Empty bytes mean the body ended cleanly. A transport failure is an Error instead — and the stream is closed for you, so a dropped connection can't leave a request running.
  • http.close is how you cancel. There's no protocol-level "stop" for a streamed response; dropping the connection is what makes a server (or a model) stop generating. Safe to call twice, and streams close automatically when the partition is torn down.
  • Chunks split anywhere — including mid-character — so a chunk is bytes, not text. Buffer with bytes.concat, find your delimiter with bytes.find, and only convert a complete line with bytes.to_string.
  • With --io=async, opening and reading both wait without blocking: other actors keep taking turns while the request is in flight and while the stream is idle between chunks. Without it, a read blocks until bytes arrive, which is fine for a script whose whole job is the stream and wrong for a server.
  • Parsing the frames is yours, deliberately: providers disagree about how a stream ends (data: [DONE], an event: marker, or just closing), so the runtime hands you the bytes rather than picking a favourite. SSE framing is about 20 lines.

#Redirects

The client never follows one. A 3xx comes back as an ordinary Ok response — the request succeeded, the server said "look elsewhere" — so you decide:

match http.get(url)
  Ok(resp) when resp.status >= 300 and resp.status < 400 -> print(`moved to {resp.headers["location"]}`)
  Ok(resp) -> use(resp)
  Error(e) -> print(e.message)
end

That's deliberate. Following a redirect automatically would take you to a host the allowlist never approved; re-requesting explicitly means every hop goes through the same check. Treat location as what it is — a string the server chose.

A request is fallible, not fatal. A connection that's refused, a host that times out, a TLS failure, a URL that doesn't parse — every one comes back as an Error value you match on, exactly like json.decode. It never crashes the script. So the ? operator chains requests cleanly:

fn latest_version()
  let resp = http.get("https://api.example.com/version")?   -- Error short-circuits out
  return json.decode(resp.body)                              -- already Ok(value) or Error: pass it on
end

A 404 or 500 is not an Error — the request succeeded, the server just answered with that status. Nor is a 3xx. Check resp.status when you care:

match http.get(url)
  Ok(resp) when resp.status == 200 -> use(resp.body)
  Ok(resp) -> print(`server said {resp.status}`)
  Error(why) -> print(`could not reach it: {why}`)
end

If all you need is whether the request got through, http.get(url) matches Ok is a boolean. if http.get(url) then is an error, not a shortcut: the Error for a refused connection is a value too, so it would count as success.

#The allowlist is a boundary

If you grant --allow-net=api.example.com, a request to any other host comes back as an Error — before a packet leaves the machine:

let r = http.get("https://evil.example/exfiltrate")   -- Error("host is not in this run's net allowlist")

So even a script you trust with network access can only talk to the hosts you named. Grant a tight allowlist and that's the script's entire reach — auditable from the command line, not buried in the code. A bare --allow-net (no list) allows any host; use it for first-party scripts, and a specific allowlist for anything you're less sure of.

#Raw TCP sockets

When you need to speak a protocol the http module doesn't — a line-based service, a database wire protocol, something custom — the same net capability gives you raw TCP via the net module. It's gated and allowlisted exactly like http: import net needs --allow-net, and net.dial checks the host against the allowlist.

import net

match net.dial("example.com", 80)
  Ok(conn) -> do
    net.write(conn, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
    match net.read(conn, 256)
      Ok(data) -> print(data)        -- up to 256 bytes; "" means the peer closed
      Error(why) -> print(why)
    end
    net.close(conn)
  end
  Error(why) -> print(`could not connect: {why}`)
end
  • net.dial(host, port, opts?) → Ok(connection) or Error. The connection is an opaque, non-sendable handle: you can't pass it to another partition in a message — the runtime refuses to copy it — so authority over a live socket can't leak across an isolation boundary.
  • net.read(conn, max) → Ok(bytes) (up to max; an empty string means the peer hung up) or Error.
  • net.write(conn, data) → Ok(bytes_written) or Error.
  • net.set_timeout(conn, limit) → Ok(nil). Limits how long each later read and write may wait; see Time limits below.
  • net.close(conn) → Ok(nil). Connections are also closed automatically when the partition is torn down, so a leaked handle won't hold a socket forever — but close them when you're done.

Reach for http for ordinary requests; reach for net when you genuinely need the bytes.

#Encrypting a raw connection with TLS

Managed databases require TLS, so a raw socket on its own will not reach RDS, Neon, Supabase or Cloud SQL. Two verbs add it, and which one you want depends on the protocol:

Protocol Use Why
PostgreSQL, MySQL, SMTP, IMAP, XMPP net.dial then net.start_tls they negotiate in-band: you connect in plaintext, exchange a request, and only then encrypt
Redis, and anything HTTPS-shaped net.dial_tls the server expects encryption from the first byte

Getting this backwards is the mistake to avoid. net.dial_tls to PostgreSQL cannot work: it starts encrypting immediately, where the server is waiting for a plaintext SSLRequest first, so the server rejects the handshake.

import net

-- The in-band shape. Connect, do the protocol's own negotiation, then upgrade.
match net.dial("db.internal", 5432)
  Ok(conn) -> do
    -- ... send the protocol's request to start TLS, read its one-byte reply ...
    match net.start_tls(conn)
      Ok(secure) -> print("encrypted")   -- the SAME connection, now encrypted
      Error(why) -> print(`could not encrypt: {why}`)
    end
  end
  Error(why) -> print(`could not connect: {why}`)
end
  • net.start_tls(conn, opts?) → Ok(connection) or Error. Encrypts an already-open connection and gives you the same handle back, so net.read, net.write and net.close carry on working. There is one connection and one identity: every reference you hold is now encrypted.
  • net.dial_tls(host, port, opts?) → Ok(connection) or Error. Exactly net.dial followed by net.start_tls.

Both check the server's certificate against your system's list of trusted authorities, and check that it was issued for the host you asked for. opts.servername overrides the name to check, and defaults to the host you dialled, which is almost always what you want.

Self-signed certificates need permission from whoever starts the program. Private-network databases often use one, so there is a way through, and it deliberately takes two steps: the call passes { insecure_skip_verify = true }, and the run must be started with --allow-net-insecure=db.internal, naming the hosts it applies to. Turning off certificate checking is a trust decision, so it belongs with the person who decided what the program may reach — not with a library three levels down that could otherwise switch it off on a run you believed was verified.

It relaxes less than the name suggests: the hostname is still checked, so the option means "I do not have your certificate authority", not "I will talk to anyone". A self-signed certificate that does not name the host you are connecting to is still refused.

The hosts you name in the flag are the ones you dialled, not whatever servername the code passes. Those are the same thing unless a script overrides the name, and keeping the flag on the dialled host is what stops a grant for one machine being moved onto a connection to another.

Two things to know. TLS comes from the host, like the HTTP client does, so a program embedded in something that supplies no TLS gets a clear Error rather than an unencrypted connection. And the list of trusted authorities is read once when the program starts, so a long-running process will not notice a system update to it until restarted.

#Time limits

Connecting gives up after 30 seconds by default, and that includes the TLS handshake, so a server that never answers cannot stall your program. Pass timeout_ms to change it for one call, as with http, or timeout_ms = 0 for no limit:

let conn = net.dial("db.internal", 5432, { timeout_ms = 5s })?
let cache = net.dial_tls("cache.internal", 6380, { timeout_ms = 2s })?   -- connecting and the handshake together
let secure = net.start_tls(conn, { timeout_ms = 10s })?

Reading and writing wait as long as it takes unless you set a limit on the connection. Some connections are meant to sit quiet for a long time: a subscriber waiting for its next message would be cut off by a default. Set one with net.set_timeout, and it applies to each read and write after that:

net.set_timeout(conn, 5s)
match net.read(conn, 4096)
  Ok(data) -> print(`got {len(data)} bytes`)
  Error(why) -> print(why)         -- net.read: timed out after 5s
end
net.set_timeout(conn, nil)          -- no limit again

Running out of time is an ordinary Error, like any other network failure, and the message names the call and the limit: net.dial: timed out after 30s, net.read: timed out after 5s. A read that times out leaves the connection open, so you can try again. A write that times out may already have sent part of the data, so close that connection rather than carrying on with it.

The limit is a duration, like 200ms, 5s or 2min. A limit of zero or less is a mistake in the program and stops it with an error. It is measured in real time, even under --fast and scua test, because the other end of a socket does not fast-forward.

#Other work runs while a connection waits

Under --io=async, a net.dial, net.dial_tls, net.read or net.write that would wait lets the program's other work run — other tasks, other partitions, the other arms of a wait_all, and other requests to an http.serve server. Reads and writes on an encrypted connection park the same way. Calls on one connection in the same direction still complete in the order they were made, exactly as they do without the flag, so a second read on the same connection waits for the first and gets the bytes after it. Only the wall-clock time changes:

-- Four queries on four pooled connections: about one round trip under --io=async, four without it.
let replies = map_all(pool, fn(conn)
  net.write(conn, query)?
  return net.read(conn, 65536)
end)

Code the runtime calls back into (a wait_all inside another wait_all's arm) still waits in place. If calls on the same connection are already waiting when one of those runs, they run first, in order, and then its own — the same order as without the flag.

Two parts of connecting still take their time in place. The TLS handshake of net.start_tls and net.dial_tls runs where it was called, once the connection is open. And looking up a host name is done by two threads the whole program shares, one lookup at a time for each partition, so a partition whose names never resolve cannot hold up the others: if 64 are already waiting, a dial returns Error("net.dial: the resolver is busy") straight away rather than queueing without end, so a burst of dials to new names is better spread out or retried. A dial to an IP address skips the lookup. See examples/net_overlap.scua for a runnable version.

#Where the client comes from

The language core carries no HTTP or TLS code at all — the http module's functions call out to a client the host supplies. The scua CLI supplies one (Zig's std.http); a C embedder supplies their own; a WebAssembly host wires the runtime's HTTP. So the embeddable build stays small and client-free, and each host uses the networking stack it already has.

Requests are blocking by default, which is right for a straight-line CLI script. When you're fanning out — many gets at once — pass --io=async and the calls wait without blocking, so under wait_all/map_all the waits overlap; the values are identical either way. Plain http:// calls then also reuse their connections from one call to the next (see --http-max-connections). And http isn't only a client: the same runtime answers requests too. See Serve HTTP requests for http.serve and the separate serve grant.