ZE-028 - HTTP Client
HTTP Client
This proposal is still a draft and is subject to change. Please do not cite or reference it as a finalized design. Features described here may not be implemented as described and cannot be used right now.
Introduction
A CLI talks to the network more often than to anything else but the file system: it checks for updates, calls an API, downloads a release, posts to a webhook. Zirric programs have no way to make an HTTP request today, other than running curl as a child process.
This proposal adds a standard library module, http, with requests, responses and a Client value. The client is a capability like fs.FileSystem or co.Timer: the real one comes from os.http(), and tests pass http.stub(handler) instead. Requests are switch points and cancellable, so they compose with the routines of ZE-025. There is no new syntax, no new keyword and no new operator.
Three rules govern the design:
- The network is a value. No function in
httpreaches the network on its own. A program that talks HTTP says so in its signatures, and the only call that touches the real network isos.http(). - A response is not an error. A
404is information.Errmeans there was no response at all;http.expectSuccessturns an unwanted status into anErrin one call. - Buffered by default, streamed on request.
sendreturns the whole body asBinary. Large or endless bodies go throughhttp.openandhttp.withStream, shaped likefs.openandfs.withFile.
Motivation
Common HTTP tasks in the target domain:
- an update check or version lookup against a registry;
- a CLI for a web API: issues, deployments, chat messages, an LLM;
- downloading a release archive to disk, with progress;
- several requests at once, with a limit and a timeout.
Today, all of these shell out to curl through the process module. That works on a developer machine and falls apart everywhere else: curl may be missing (Windows, minimal containers), errors arrive as exit codes and text on standard error, headers have to be parsed from its output, and a test either hits the real network or replaces a whole executable on the PATH.
With http, an update check is a function over a Client:
mod code.example.updates
import bytes
import coding
import http
import json
// The part of the registry's answer this program reads.
data Release {
version: String
}
// Returns the latest released version of the package called name.
fn latestVersion(client: http.Client, name: String) -> String! {
const body = http.expectSuccess(http.get(client, "https://registry.example.com/" + name))!.body
const tree = json.parse(bytes.toString(body))
if tree is Err {
return tree
}
return Ok(coding.decodeWith(json, Release, tree.value)!.version)
}
Its test never opens a socket:
mod code.example.updates._t
import bytes
import http
import tests
import tests.assert
import code.example.updates
@tests.Test()
fn testLatestVersion() -> Result {
const client = http.stub(fn(request) {
if request.url != "https://registry.example.com/zirric" {
return http.respond(404, bytes.fromString(""))
}
return http.respond(200, bytes.fromString("{\"version\": \"0.3.0\"}"))
})
return assert.equal(Ok("0.3.0"), updates.latestVersion(client, "zirric"))
}
@tests.Test()
fn testOfflineIsErr() -> Result {
return assert.isErr(updates.latestVersion(http.offline(), "zirric"))
}
Only the entry point reaches for the real network:
const version = updates.latestVersion(os.http(), "zirric")
Concurrency needs nothing new. ZE-025 already has a bounded map and a timeout:
fn fetchAll(client: http.Client, timer: co.Timer, urls: [String]) -> [Result] {
return co.map(urls, 4, fn(url) {
co.timeout(timer, time.seconds(10), fn() { http.get(client, url) })
})
}
Proposed Solution
A new module, http:
| Declaration | Meaning |
|---|---|
data Request { method, url, headers, body } |
What to send. method: String, url: String, headers: Dict, body: Binary. |
data Response { status, headers, body } |
A complete response. status: Int, headers: Dict, body: Binary. |
data Stream { status, headers, readFrom, closeWith } |
A response whose body is still arriving. Carries @io.Reader. Must be closed. |
data Client { open: fn(Request) -> Result } |
The capability to make requests. open returns Ok(Stream). |
attr HasClient |
Provides a Client, like fs.HasFileSystem provides a file system. |
open(client: Client, request: Request) -> Result |
Sends request, returns Ok(Stream) once the status and headers have arrived. |
close(stream: Stream) -> Result |
Releases the connection. The unread rest of the body is dropped. |
withStream(opened: Result, body: fn(Stream) -> Result) -> Result |
Runs body on an opened stream and closes it, whether or not body succeeded. |
send(client: Client, request: Request) -> Result |
open, read the whole body, close. Returns Ok(Response). |
get(client: Client, url: String) -> Result |
send of a GET without headers or body. |
post(client: Client, url: String, contentType: String, body: Binary) -> Result |
send of a POST with a content-type. |
request(method: String, url: String) -> Request |
A request without headers or body. |
withHeader(request: Request, name: String, value: String) -> Request |
A copy of request with one more header value. |
withBody(request: Request, contentType: String, body: Binary) -> Request |
A copy of request with a body and its content-type. |
header(headers: Dict, name: String) -> Option |
The first value of a header, looked up without regard to case. |
expectSuccess(result: Result) -> Result |
Passes an Err and a 2xx response through; any other status becomes an Err. |
query(url: String, params: Dict) -> String |
url with params appended as an escaped query string. |
form(fields: Dict) -> Binary |
fields as an application/x-www-form-urlencoded body. |
respond(status: Int, body: Binary) -> Response |
A response without headers. For stubs. |
stub(handler: fn(Request) -> Response) -> Client |
A client that answers every request by calling handler. For tests. |
offline() -> Client |
A client whose every request fails with Unreachable. For tests. |
data InvalidRequest { reason: String } |
@Error: the request could not be sent as given (bad URL, bad method). |
data Unreachable { url: String, reason: String } |
@Error: no response arrived (DNS, connection, TLS, too many redirects). |
data UnexpectedStatus { status: Int, body: Binary } |
@Error: returned by expectSuccess for a status outside 200–299. |
One addition outside http: os.http() -> http.Client, the real client.
The client is a value
Client has exactly one primitive, open. Everything else in the module is a plain Zirric function over it, so a client with extra behavior is a function returning a Client:
// A client that sends a bearer token with every request.
fn authorized(client: http.Client, token: String) -> http.Client {
return http.Client(fn(request) {
return http.open(client, http.withHeader(request, "authorization", "Bearer " + token))
})
}
Logging, base URLs, retries and recording for tests are written the same way. There is no middleware concept, no registry and no global configuration: what a client does is visible where it is built.
Streaming
send is right for API calls and small files. A download or a streamed answer uses open and withStream, exactly like a file:
// Downloads url into path on fsys, chunk by chunk.
fn download(client: http.Client, fsys: fs.FileSystem, url: String, path: String) -> Result {
return http.withStream(http.open(client, http.request("GET", url)), fn(stream) {
if stream.status != 200 {
return Err(errors.wrap(http.UnexpectedStatus(stream.status, bytes.fromString("")), url))
}
return fs.withFile(fs.create(fsys, path), fn(file) {
for {
const chunk = io.read(stream, 32768)
if len(chunk) == 0 {
return Ok(void)
}
_ = io.write(file, chunk)
}
})
})
}
A Stream carries @io.Reader, so everything that reads from a reader reads from a response.
Testing
stub turns a function into a server. The handler sees the full Request, so a test can check what was sent as easily as what comes back:
@tests.Test()
fn testSendsToken() -> Result {
var seen = ""
const client = http.stub(fn(request) {
seen = http.header(request.headers, "Authorization") ?? ""
return http.respond(204, bytes.fromString(""))
})
_ = http.get(authorized(client, "secret"), "https://api.example.com/me")
return assert.equal("Bearer secret", seen)
}
offline covers the other side: every code path that must survive a missing network is tested without pulling a cable.
Detailed Design
Requests
| Rule | Consequence |
|---|---|
url must be absolute, with the scheme http or https. |
Otherwise open returns Err(InvalidRequest(…)) without sending. |
method is sent as written. It must be a non-empty token; request does not uppercase it. |
"get" is a different method from "GET", as in HTTP. |
headers maps lower-case names to arrays of values. |
Repeated headers keep all their values, in order. |
withHeader lower-cases name and appends value. |
Case never decides whether two headers are the same. |
body is always Binary. An empty body is sent as no body. |
Text is converted with bytes.fromString; JSON with json.format first. |
content-length and host are computed by the client and cannot be set. |
A request cannot lie about its own framing. |
Request has no optional fields, since construction is positional (ZE-003 was rejected). request, withHeader and withBody fill that gap with plain functions. A JSON body reads:
fn createIssue(client: http.Client, title: String) -> Result {
const payload = json.format(["title": title])
if payload is Err {
return payload
}
const request = http.request("POST", "https://api.example.com/issues")
return http.expectSuccess(http.send(client, http.withBody(request, "application/json", bytes.fromString(payload.value))))
}
Responses
Response.headersandStream.headersuse the same shape as requests: lower-case names, arrays of values.header(headers, name)lower-casesnameand returnsSomeof the first value, orNone().sendreturnsOk(Response)for every status the server sends,1xxexcluded (the client handles those itself).expectSuccess(Ok(r))isOk(r)for200 <= r.status < 300, otherwiseErr(UnexpectedStatus(r.status, r.body)). The body is kept because APIs explain their errors there.expectSuccess(Err(e))isErr(e).UnexpectedStatusdoes not name the URL: aResponsedoes not know its request, and hiding it there would be magic. Callers add context witherrors.wrap, as for every other error.sendreads the body into memory without a limit. Unknown or large bodies are whatopenis for.
Streams
| Rule | Consequence |
|---|---|
open returns once status and headers have arrived. |
A program can decide on the status before reading any of the body. |
io.read(stream, n) returns up to n bytes, and an empty Binary at the end. |
The same contract as every other @io.Reader. |
| A dropped connection mid-body ends the stream early. | close then returns Err(Unreachable(…)), so withStream reports it. |
close on a closed stream does nothing. |
withStream and an explicit close never conflict. |
| A stream that is never closed holds its connection until the program ends. | withStream is the recommended form, as fs.withFile is for files. |
Scheduling and cancelling
Under ZE-025, http belongs to the outside world:
open,send,get,postand reading aStreamthroughio.readare switch points that always give way, whether the client isos.http()or astub. The module decides, not the value behind it, so a test with a stub sees the same interleaving points as production.- A request in flight runs on its own goroutine, like a host stream. The routine that made it waits at an ordinary, cancellable switch point.
Cancelling follows ZE-025’s split between what can be kept and what is already out:
| In flight | On cancel |
|---|---|
| Waiting for status and headers | Stops at once. The connection is aborted. The request may already have reached the server; whether it acted on it is unknown, as with a write. |
Reading the body of a Stream |
Stops at once. The connection is dropped. |
A stub handler |
Runs to its end, like any Zirric code between switch points. Its response is discarded. |
A stopped routine never leaks a connection. The host ties every open stream to the routine that opened it and releases it when that routine ends, however it ends. withStream is still the right form; this rule only makes cancellation safe.
There is no timeout in Request or Client. co.timeout is the one way to give up on something, for HTTP as for everything else.
The real client
os.http() returns one client per process. Connections are reused between requests. Its behavior is fixed, not configurable:
| Concern | Behavior |
|---|---|
| TLS | Verified against the system’s root certificates. There is no switch to turn verification off. |
| Redirects | Followed up to ten times for GET and HEAD. Other methods return the 3xx response as it is. |
| Proxies | Taken from HTTP_PROXY, HTTPS_PROXY and NO_PROXY, as most command line tools do. |
| Compression | gzip is requested and decoded. The body is the decoded one; content-encoding is removed from headers. |
user-agent |
zirric/<version> unless the request sets one. |
| Cookies | Not stored. A set-cookie header is returned like any other. |
Anything a program needs beyond this, such as a base URL, a token or a retry, is a function over Client (see above), not a setting.
Static checks, tooling
Nothing new is needed. http is ordinary Zirric: data, one attribute, functions and a few externs behind os.http(). A Stream passed where @io.Reader is expected passes ZE-022. There is no new grammar, so the formatter, language server and tree-sitter grammar need no work.
Deliberately not
- A server. Zirric’s domain is CLI and TUI programs, which call services far more often than they are one. A server would need its own proposal, built on the same
RequestandResponse. - Module-level shortcuts without a client. No
http.get(url). The network is always passed in. - Timeouts and retries as options.
co.timeoutand aforloop withco.sleepalready express them. - Non-
2xxasErrby default.expectSuccessis one call away. - Automatic JSON.
json.parse,json.formatandcodingalready do it, and stay swappable. - Middleware, interceptors, hooks. A function returning a
Clientis the whole mechanism. - Disabling TLS verification. A test uses
stub; a development server gets a trusted certificate. - Streaming request bodies, multipart helpers, WebSockets, HTTP/3 control, a cookie jar. All can be added later without breaking anything.
- A URL type. URLs are strings;
querycovers the common escaping need. Aurlsmodule, likepaths, is an open question.
Changes to the Standard Library
- New module
http, as listed above. os.http() -> http.Client.osdepends onhttp, as it does onfs,clockandrandom.
Compatibility
Nothing breaks. http is new, and os only gains a function.
Dependencies on Other Proposals
| Proposal | Dependency |
|---|---|
| ZE-008 | Every request returns a Result; InvalidRequest, Unreachable and UnexpectedStatus carry @Error. |
| ZE-018 | Stream carries @io.Reader; os hands out the real client. |
| ZE-019 | !.body, ?? and !! work on what send and header return. |
| ZE-021 | JSON bodies go through json and coding, not through http. |
| ZE-025 | Requests are switch points and cancellable; timeouts and concurrency come from co. |
Alternatives Considered
Do nothing: shell out to curl
The process module already lets a program run curl, and nothing new would be needed in the standard library. For a personal script on a developer machine, that is enough.
It loses because every property the standard library is built for is gone. curl is not everywhere; its errors are exit codes and text; headers and status must be parsed back out of its output; and a test can only replace it by putting a fake executable on the PATH. The one thing a CLI talks to most would be the one thing it cannot test.
Module-level functions, no client
http.get(url), as in most scripting languages, is shorter in a ten-line script. It hides the network inside every function that calls it, so nothing in a signature says a function needs it, and a test can only replace it with a global switch. That is the habit the standard library is meant to make visible at the import. http.get(os.http(), url) costs one argument.
An @http.Client attribute instead of a data value
An attribute would let any type act as a client. But the set of behaviors is one function, and a data value with a function field already accepts any behavior: stub, offline and authorized are all plain Clients. An attribute adds a concept and buys nothing, which is also why fs.FileSystem and random.Source are values.
Non-2xx responses as Err
Saves expectSuccess in the common case. But a 404 is often the answer a program wants (“does this release exist?”), a 304 is not a failure, and APIs return error details in bodies a program needs to read. Folding them into Err would force callers to dig the response back out of the error. Keeping the two apart is also what ZE-008 asks for: Err for what went wrong, values for what came back.
Always streamed bodies
One response type instead of two. But every API call would have to open, read and close, and a forgotten close would leak a connection in the simplest script. send covers the common case; Stream stays one step away.
Options on the client: timeouts, redirects, TLS, base URL
A configurable client, as in Go or Python, answers every need with a field. It also means every client needs defaults, every field needs documentation and tests, and a reader must look at where a client was built to know what a call does. Timeouts already live in co, a base URL or token is a three-line wrapper, and the rest are fixed on purpose. A setting can be added later; a setting cannot be taken back.
Other module names
| Name | Why not |
|---|---|
net.http |
There is no net to put next to it, and nesting would be the only reason for it. |
web |
Suggests a server and HTML as much as a client. |
fetch |
Names one function, not the module. |
Open Questions
- Redirects. Should the final URL after redirects be part of
Response? It matters for downloads behind short links, but the field would be meaningless forstubresponses. - Body limits. Should
sendtake, or apply, a maximum body size, so an unexpected endless body cannot exhaust memory? - A
urlsmodule.querycovers building URLs. Parsing and joining them, likepathsfor file paths, may deserve its own module. - Recording. A client that records real exchanges and replays them in tests would make stubs for large APIs cheap. Should the standard library ship it, or leave it to a package?
- Streaming uploads. A request body from an
@io.Readerwould allow uploading large files without loading them. Is it needed before a server exists?
Acknowledgements
Prior art: Go’s net/http and its RoundTripper for the single-function client; Python’s requests and httpx for the buffered default; Rust’s reqwest for keeping status checks explicit (error_for_status); Elixir’s Req and Tesla for clients built by wrapping clients.