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, 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

match http.get("https://api.example.com/status")
  Ok(resp) -> print(`got {resp.status}: {resp.body}`)
  Error(why) -> print(`request failed: {why}`)
end
  • 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)?
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

#The allowlist is a boundary

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

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)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.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.

#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 run on an offload pool, so under wait_all/map_all the waits overlap; the results are identical either way. 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.