Making Outgoing HTTP Requests (MoonBit)
Overview
MoonBit Golem agents use the SDK’s @http package (golemcloud/golem_sdk/http) for outgoing HTTP requests. This package re-exports the WASI HTTP types and the outgoing handler function.
All outgoing HTTP requests made from a Golem agent are automatically durably persisted — Golem records the request and response in the oplog, so on replay the response is read from the log rather than re-executing the network call.
Setup
Add the HTTP package to your agent’s moon.pkg:
import {
"golemcloud/golem_sdk/http" @http,
}No WIT changes or binding regeneration is needed — the SDK already includes the HTTP imports.
GET Request
fn make_get_request(url_authority : String, path : String) -> String {
// 1. Create headers
let headers = @http.Fields::new()
// 2. Create outgoing request (defaults to GET)
let request = @http.OutgoingRequest::new(headers)
let _ = request.set_scheme(Some(@http.Https))
let _ = request.set_authority(Some(url_authority))
let _ = request.set_path_with_query(Some(path))
// 3. Finish the body (empty for GET)
let body = request.body().unwrap()
@http.OutgoingBody::finish(body, None).unwrap()
// 4. Send the request
let future_response = @http.handle(request, None).unwrap()
// 5. Wait for the response
let pollable = future_response.subscribe()
pollable.block()
let response = future_response.get().unwrap().unwrap().unwrap()
// 6. Read status
let status = response.status()
// 7. Read response body
let incoming_body = response.consume().unwrap()
let stream = incoming_body.stream().unwrap()
let bytes = stream.blocking_read(1048576UL).unwrap() // read up to 1MB
stream.drop()
@http.IncomingBody::finish(incoming_body)
let body_str = String::from_utf8_lossy(bytes)
body_str
}POST with JSON Body
fn make_post_request(
authority : String,
path : String,
json_body : String
) -> (UInt, String) {
// 1. Create headers with Content-Type
let headers = @http.Fields::from_list(
[
("Content-Type", b"application/json"),
("Accept", b"application/json"),
],
).unwrap()
// 2. Create request and set method to POST
let request = @http.OutgoingRequest::new(headers)
let _ = request.set_method(@http.Post)
let _ = request.set_scheme(Some(@http.Https))
let _ = request.set_authority(Some(authority))
let _ = request.set_path_with_query(Some(path))
// 3. Write the request body
let body = request.body().unwrap()
let output_stream = body.write().unwrap()
let body_bytes = json_body.to_utf8_bytes()
output_stream.blocking_write_and_flush(body_bytes).unwrap()
output_stream.drop() // must drop stream before finishing body
@http.OutgoingBody::finish(body, None).unwrap()
// 4. Send and wait
let future_response = @http.handle(request, None).unwrap()
let pollable = future_response.subscribe()
pollable.block()
let response = future_response.get().unwrap().unwrap().unwrap()
// 5. Read response
let status = response.status()
let incoming_body = response.consume().unwrap()
let stream = incoming_body.stream().unwrap()
let bytes = stream.blocking_read(1048576UL).unwrap()
stream.drop()
@http.IncomingBody::finish(incoming_body)
(status, String::from_utf8_lossy(bytes))
}Setting Headers
Headers are Fields resources. Field values are FixedArray[Byte] (the WASI field-value type):
// From a list of (name, value) pairs
let headers = @http.Fields::from_list(
[
("Authorization", b"Bearer my-token"),
("Accept", b"application/json"),
("X-Custom-Header", b"custom-value"),
],
).unwrap()
// Or construct empty and append
let headers = @http.Fields::new()
let _ = headers.append("Authorization", b"Bearer my-token")
let _ = headers.append("Content-Type", b"application/json")Reading Response Headers
let response = future_response.get().unwrap().unwrap().unwrap()
// Status code (UInt)
let status = response.status()
// Response headers (immutable Fields)
let resp_headers = response.headers()
let content_type_values = resp_headers.get("Content-Type")
// content_type_values : Array[FixedArray[Byte]]Setting Timeouts
Use RequestOptions to configure transport-level timeouts:
let options = @http.RequestOptions::new()
let _ = options.set_connect_timeout(Some(5_000_000_000UL)) // 5 seconds in nanoseconds
let _ = options.set_first_byte_timeout(Some(10_000_000_000UL)) // 10 seconds
let _ = options.set_between_bytes_timeout(Some(30_000_000_000UL)) // 30 seconds
let future_response = @http.handle(request, Some(options)).unwrap()Error Handling
fn fetch_data(authority : String, path : String) -> Result[String, String] {
let headers = @http.Fields::new()
let request = @http.OutgoingRequest::new(headers)
let _ = request.set_scheme(Some(@http.Https))
let _ = request.set_authority(Some(authority))
let _ = request.set_path_with_query(Some(path))
let body = request.body().unwrap()
@http.OutgoingBody::finish(body, None).unwrap()
match @http.handle(request, None) {
Err(error_code) => Err("Request failed: " + error_code.to_string())
Ok(future_response) => {
let pollable = future_response.subscribe()
pollable.block()
match future_response.get() {
Some(Ok(Ok(response))) => {
let status = response.status()
let incoming_body = response.consume().unwrap()
let stream = incoming_body.stream().unwrap()
let bytes = stream.blocking_read(1048576UL).unwrap()
stream.drop()
@http.IncomingBody::finish(incoming_body)
if status >= 200 && status < 300 {
Ok(String::from_utf8_lossy(bytes))
} else {
Err(
"HTTP " + status.to_string() + ": " +
String::from_utf8_lossy(bytes),
)
}
}
Some(Ok(Err(error_code))) =>
Err("HTTP error: " + error_code.to_string())
Some(Err(_)) => Err("Response already consumed")
None => Err("Response not ready")
}
}
}
}Reading Large Response Bodies
The blocking_read call returns up to the requested number of bytes. For larger responses, read in a loop:
fn read_full_body(incoming_body : @http.IncomingBody) -> FixedArray[Byte] {
let stream = incoming_body.stream().unwrap()
let chunks : Array[FixedArray[Byte]] = []
loop {
match stream.blocking_read(65536UL) {
Ok(chunk) => {
if chunk.length() == 0 {
break
}
chunks.push(chunk)
}
Err(@http.StreamError::Closed) => break
Err(e) => panic()
}
}
stream.drop()
@http.IncomingBody::finish(incoming_body)
// Concatenate chunks
let total = chunks.fold(init=0, fn(acc, c) { acc + c.length() })
let result = FixedArray::make(total, b'\x00')
let mut offset = 0
for chunk in chunks {
chunk.blit_to(result, len=chunk.length(), src_offset=0, dst_offset=offset)
offset += chunk.length()
}
result
}Complete Example in an Agent
/// An agent that fetches data from an external API
#derive.agent
pub(all) struct DataFetcher {
base_url : String
mut last_result : String
}
///|
fn DataFetcher::new(base_url : String) -> DataFetcher {
{ base_url, last_result: "" }
}
///|
/// Fetch data from the configured API endpoint
pub fn DataFetcher::fetch(self : Self, path : String) -> String {
let headers = @http.Fields::from_list(
[("Accept", b"application/json")],
).unwrap()
let request = @http.OutgoingRequest::new(headers)
let _ = request.set_scheme(Some(@http.Https))
let _ = request.set_authority(Some(self.base_url))
let _ = request.set_path_with_query(Some(path))
let body = request.body().unwrap()
@http.OutgoingBody::finish(body, None).unwrap()
let future_response = @http.handle(request, None).unwrap()
let pollable = future_response.subscribe()
pollable.block()
let response = future_response.get().unwrap().unwrap().unwrap()
let incoming_body = response.consume().unwrap()
let stream = incoming_body.stream().unwrap()
let bytes = stream.blocking_read(1048576UL).unwrap()
stream.drop()
@http.IncomingBody::finish(incoming_body)
let result = String::from_utf8_lossy(bytes)
self.last_result = result
result
}Calling Golem Agent HTTP Endpoints
When making HTTP requests to other Golem agent endpoints, the request body must match the Golem HTTP body mapping convention: non-binary body parameters are always deserialized from a JSON object where each top-level field corresponds to a method parameter name. This is true even when the endpoint has a single body parameter.
For example, given an agent endpoint:
#derive.endpoint(post = "/record")
pub fn RecorderAgent::record(self : Self, body : String) -> Unit { ... }The correct HTTP request body is:
// ✅ CORRECT — JSON object with parameter name as key
let json_body = "{\"body\": \"hello\"}"
// ❌ WRONG — raw string does NOT match Golem's JSON body mapping
let json_body = "\"hello\""Rule of thumb: If the target endpoint is a Golem agent, always send
Content-Type: application/jsonwith parameter names as JSON keys.
Resource Lifecycle
WASI HTTP uses resource handles that must be dropped in the correct order:
- OutputStream must be dropped before calling
OutgoingBody::finish - OutgoingBody must be finished (not just dropped) to signal the body is complete
- InputStream must be dropped before calling
IncomingBody::finish - IncomingBody must be finished to signal you’re done reading
Dropping a resource out of order will cause a trap.
Key Constraints
- All HTTP types are WASI resources with strict ownership and drop ordering
- Field values (
field-value) areFixedArray[Byte], not strings — use byte literals (b"...") or.to_utf8_bytes() - The
blocking_readfunction reads up to the requested number of bytes — for large responses, read in a loop - HTTP requests are automatically durably persisted by Golem — no manual durability wrapping is needed
- The
Methodvariant defaults toGetwhen constructing anOutgoingRequest