HTTP Class
HTTP client class for GET, POST, PUT, PATCH, DELETE requests with JSON support and parallel requests.
HTTP Class
All HTTP functions are accessed via the HTTP class:
response = HTTP.get("https://api.example.com/users")
response = HTTP.post("https://api.example.com/users", { "name": "Alice" })
response = HTTP.request("DELETE", "https://api.example.com/users/1")
SSRF blocklist & redirects
Every URL passed to HTTP.* is validated up-front: schemes other than http/https are rejected, as are hosts that resolve to loopback / private / link-local IP ranges. A request to http://169.254.169.254/... (cloud metadata) or http://10.0.0.1/ fails immediately. The blocklist also covers 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15 and 240.0.0.0/4, and IPv6 addresses that embed a blocked IPv4 — NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4 (2002::/16), Teredo and IPv4-compatible forms.
Auto-redirects are not followed by the synchronous HTTP.get / HTTP.post / HTTP.request paths — a 3xx response is returned as-is so a redirect-controlled Location cannot bypass the blocklist. Asynchronous and Model-driven HTTP (the reqwest-backed paths) follow redirects with a custom policy that re-runs the SSRF check on every hop.
Apps that need to follow a 3xx from HTTP.get should inspect response["status"] and response["headers"]["location"] and re-issue the request manually.
Connections & error messages
Connections are pooled and reused across calls, per host, for 15 seconds of idle time — a second request to an API you just called skips the TCP and TLS handshake. Pooling behaves the same for HTTP/1.1 and HTTP/2 hosts.
A transport failure reports its whole cause chain, not just the top line: Request failed: error sending request for url (…) is followed by what actually went wrong (dns error: …, connection closed before message completed, invalid peer certificate). Worth surfacing wherever you handle the {"error": ...} hashes the parallel helpers return.
Basic HTTP Requests
HTTP.get(url, options?)
Perform an HTTP GET request. Returns a Future that resolves to the response body as a string.
resp = HTTP.get("https://api.example.com/users")
if resp["status"] == 200
println(resp["body"])
end
# Request headers ride along in the options hash.
me = HTTP.get("https://api.example.com/me", {
"headers": { "Authorization": "Bearer " + token, "X-Client": "soli" }
})
# Per-call timeout in seconds (overrides the 30s default for this request).
resp = HTTP.get("https://api.example.com/slow", { "timeout": 5 })
get, post,
put, patch,
delete, head, the
*_json / get_jsonp variants and
(applied to every URL) get_all / get_all_json
all take the same trailing options hash with two keys:
headers— a hash ofname => value. Strings go out as-is, numbers and booleans are stringified, anullvalue skips that header. A header you set replaces the builtin's own default (Content-Typeonpost/put/patch,Accepton the JSON variants), matched case-insensitively, so you never send two. A name or value the client would refuse (spaces in the name, a CR/LF in the value) raises before anything is sent — the message names the header but never echoes the value, so a rejected token stays out of the error page and logs.Content-LengthandTransfer-Encodingare refused outright: they are derived from the body, and a caller-supplied length would be sent verbatim and desync the upstream connection. Redirects: the client dropsAuthorization,CookieandProxy-Authorizationwhen a redirect changes host or port, but any other header you set (anX-Api-Key, say) follows the redirect to the new host.timeout— per-call timeout in seconds (IntorFloat). Overrides the default 30s client timeout for that one request; must be positive, fractions such as0.5are allowed.
HTTP.request, the third argument is a flat headers hash: its
timeout key is consumed rather than sent as a header, and a nested
headers hash is merged in, so the same options shape works there too. Flat and nested pairs go through the same validation.
HTTP.get_jsonp(url, options?)
Fetch a JSONP endpoint and unwrap the callback(...) padding, returning the parsed value. Use it to consume legacy cross-origin APIs that only expose JSONP; put the ?callback=... name in the URL you pass.
feed = HTTP.get_jsonp("https://api.example.com/feed?callback=cb")
println(feed["items"][0])
HTTP.post(url, body, options?)
Perform an HTTP POST request with a body. A hash body is sent as JSON, a string body as text/plain; a Content-Type in options.headers replaces that default.
resp = HTTP.post(
"https://api.example.com/users",
"name=Alice",
{ "headers": { "Content-Type": "application/x-www-form-urlencoded" } }
)
HTTP.put(url, body, options?)
Perform an HTTP PUT request with a body.
resp = HTTP.put(
"https://api.example.com/users/1",
{ "name": "Alice Updated" }
)
HTTP.delete(url, options?)
Perform an HTTP DELETE request.
resp = HTTP.delete("https://api.example.com/users/1")
HTTP.patch(url, body, options?)
Perform an HTTP PATCH request with a body.
resp = HTTP.patch(
"https://api.example.com/users/1",
{ "email": "new_email@example.com" }
)
HTTP.head(url, options?)
Perform an HTTP HEAD request.
resp = HTTP.head("https://api.example.com/users")
println(resp["headers"])
HTTP.download(url, path, options?)
Fetch a URL and write the body to a file, returning the number of bytes written.
The one method here that does not decode: every other call ends in
UTF-8, which a picture, a sound or a PDF is not. Same SSRF check, same
SOLI_HTTP_MAX_RESPONSE_BYTES cap, and the path
goes through the same jail as the File builtins
— a missing parent directory is created, an escaping path is refused, and a
symlink standing where the file should go is not followed. Blocking: it returns when
the file is on disk.
bytes = HTTP.download("https://example.com/cover.jpg", "public/covers/1.jpg")
println("wrote " + str(bytes))
JSON HTTP Methods
HTTP.get_json(url, options?)
GET request with automatic JSON parsing of response body. Sends Accept: application/json unless options.headers overrides it.
data = HTTP.get_json("https://api.example.com/users/1")
println(data["body"]["name"])
# Authenticated, with a 10-second budget.
me = HTTP.get_json("https://api.example.com/me", {
"headers": { "Authorization": "Bearer " + token },
"timeout": 10
})
HTTP.post_json(url, data, options?)
POST request with automatic JSON serialization.
resp = HTTP.post_json(
"https://api.example.com/users",
{ "name": "Alice", "email": "alice@example.com" }
)
HTTP.put_json(url, data, options?)
PUT request with automatic JSON serialization.
resp = HTTP.put_json(
"https://api.example.com/users/1",
{ "name": "Alice Updated", "email": "alice_updated@example.com" }
)
HTTP.patch_json(url, data, options?)
PATCH request with automatic JSON serialization.
resp = HTTP.patch_json(
"https://api.example.com/users/1",
{ "email": "new_email@example.com" }
)
Generic HTTP Request
HTTP.request(method, url, headers?, body?)
Perform any HTTP method (GET, POST, PUT, PATCH, DELETE, etc.). The headers hash accepts a timeout key (seconds) that is consumed as the per-call timeout rather than sent as a header.
resp = HTTP.request("DELETE", "https://api.example.com/users/1")
resp = HTTP.request("PATCH", url, headers, json)
# Custom header plus a 3-second per-call timeout.
resp = HTTP.request("GET", url, { "Authorization": "Bearer " + token, "timeout": 3 })
Status Code Helpers
http_ok(resp)
Check if status is exactly 200
http_success(resp)
Check if status is 2xx
http_redirect(resp)
Check if status is 3xx
http_client_error(resp)
Check if status is 4xx
http_server_error(resp)
Check if status is 5xx
JSON Helpers
HTTP.json_parse(string)
Parse a JSON string into a Soli value
HTTP.json_stringify(value)
Convert a Soli value to a JSON string
Parallel Requests
HTTP.get_all(urls, options?)
Execute multiple GET requests in parallel. Returns response bodies as strings; failed requests appear as {"error": ...} hashes. An optional trailing options hash (headers, timeout — see HTTP.get) applies to every request in the batch.
responses = HTTP.get_all([
"https://api.example.com/users",
"https://api.example.com/posts"
])
# Cap every request in the batch at 5 seconds.
responses = HTTP.get_all([
"https://api.example.com/users",
"https://api.example.com/posts"
], { "timeout": 5 })
HTTP.get_all_json(urls, options?)
Execute multiple GET requests in parallel and parse each body as JSON. Failed requests, non-2xx responses, or unparseable bodies appear as {"error": ...} hashes. Accepts the same optional trailing options hash (headers, timeout) as HTTP.get_all.
responses = HTTP.get_all_json([
"https://api.example.com/users.json",
"https://api.example.com/posts.json"
])
users = responses[0]
if users.has_key("error") {
print("users failed: " + users["error"])
}
HTTP.parallel(requests)
Execute multiple requests of different methods in parallel. Each request hash accepts method, url, optional headers, optional body, and an optional per-request timeout (seconds).
responses = HTTP.parallel([
{ "method": "GET", "url": "https://api.example.com/users", "timeout": 5 },
{ "method": "POST", "url": "https://api.example.com/logs", "body": "{}" }
])