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 resource types (OutgoingRequest, Fields, IncomingResponse, …) and exposes the handle function that drives the outgoing handler.
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. Because the @http re-export only covers the resource types (not the Method / Scheme variant constructors), also import the underlying types package and the streams error type:
import {
"golemcloud/golem_sdk/http" @http,
"golemcloud/golem_sdk/interface/wasi/http/types" @types,
"golemcloud/golem_sdk/interface/wasi/io/streams" @streams,
}For UTF-8 encoding/decoding of bodies, also depend on the standard library encoders:
import {
"moonbitlang/core/encoding/utf8" @utf8,
}No WIT changes or binding regeneration is needed — the SDK already includes the HTTP imports.
Key API Facts (don’t guess these)
The MoonBit HTTP API does not match TypeScript/Rust naming. Use exactly these constructors/methods:
| What | Correct call | Wrong (does not exist) |
|---|---|---|
New Fields | @types.Fields::fields() | @http.Fields::new() |
Build Fields from list | @types.Fields::from_list([...]) | — |
New OutgoingRequest | @types.OutgoingRequest::outgoing_request(headers) | @http.OutgoingRequest::new(...) |
New RequestOptions | @types.RequestOptions::request_options() | @http.RequestOptions::new() |
| Method variants | @types.Get, @types.Post, @types.Put, @types.Delete, @types.Patch, @types.Head, @types.Options, @types.Connect, @types.Trace, @types.Other(s) | @http.Post |
| Scheme variants | @types.Http, @types.Https, @types.Other(s) | @http.Http |
| Get current method | request.method_() (note trailing _) | request.method() |
| Set method | request.set_method(@types.Post) | — |
| Send request | @http.handle(request, options?) | — |
| Field values | FixedArray[Byte] | Bytes (b"..." literals) |
String → UTF-8 FixedArray[Byte] | @utf8.encode(s).to_fixedarray() | s.to_utf8_bytes() |
FixedArray[Byte] → UTF-8 String | @utf8.decode_lossy(Bytes::from_fixedarray(arr).view()) | String::from_utf8_lossy(arr) |
Note: String::to_bytes() exists but returns UTF-16LE bytes — it is the wrong encoding for HTTP. Always use @utf8.encode for HTTP bodies and headers.
Helpers
These two helpers handle the byte/string conversions you need everywhere; copy them into your agent file:
///| Convert a UTF-8 String to a `FixedArray[Byte]` (the WASI `field-value` / body type).
fn str_to_bytes(s : String) -> FixedArray[Byte] {
@utf8.encode(s).to_fixedarray()
}
///| Decode a `FixedArray[Byte]` as UTF-8 into a `String` (lossy — invalid sequences become U+FFFD).
fn bytes_to_str(bs : FixedArray[Byte]) -> String {
@utf8.decode_lossy(Bytes::from_fixedarray(bs).view())
}GET Request
///|
fn make_get_request(authority : String, path : String) -> String {
// 1. Headers (empty).
let headers = @types.Fields::fields()
// 2. Build the request (defaults to GET).
let request = @types.OutgoingRequest::outgoing_request(headers)
let _ = request.set_scheme(Some(@types.Https))
let _ = request.set_authority(Some(authority))
let _ = request.set_path_with_query(Some(path))
// 3. Finish the (empty) outgoing body.
let body = request.body().unwrap()
@types.OutgoingBody::finish(body, None).unwrap()
// 4. Send.
let future_response = @http.handle(request, None).unwrap()
// 5. Wait for the response (FutureIncomingResponse + Pollable).
let pollable = future_response.subscribe()
pollable.block()
// get() returns Option[Result[Result[IncomingResponse, ErrorCode], Unit]]
let response = future_response.get().unwrap().unwrap().unwrap()
// 6. Read status (UInt) and body.
let _status = response.status()
let incoming_body = response.consume().unwrap()
let stream = incoming_body.stream().unwrap()
let bytes = stream.blocking_read(1048576UL).unwrap() // up to 1 MiB
stream.drop()
let _ = @types.IncomingBody::finish(incoming_body)
bytes_to_str(bytes)
}POST with JSON Body
///|
fn make_post_request(
authority : String,
path : String,
json_body : String,
) -> (UInt, String) {
// 1. Headers — note: field values are FixedArray[Byte], not Bytes.
let headers = @types.Fields::from_list([
("Content-Type", str_to_bytes("application/json")),
("Accept", str_to_bytes("application/json")),
]).unwrap()
// 2. Build request and switch method to POST.
let request = @types.OutgoingRequest::outgoing_request(headers)
let _ = request.set_method(@types.Post)
let _ = request.set_scheme(Some(@types.Https))
let _ = request.set_authority(Some(authority))
let _ = request.set_path_with_query(Some(path))
// 3. Write the body.
let body = request.body().unwrap()
let output_stream = body.write().unwrap()
output_stream.blocking_write_and_flush(str_to_bytes(json_body)).unwrap()
output_stream.drop() // MUST drop the stream before finishing the body
@types.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()
let _ = @types.IncomingBody::finish(incoming_body)
(status, bytes_to_str(bytes))
}Setting Headers
Fields is a WASI resource. Field values are FixedArray[Byte]:
// From a list of (name, value) pairs.
let headers = @types.Fields::from_list([
("Authorization", str_to_bytes("Bearer my-token")),
("Accept", str_to_bytes("application/json")),
("X-Custom-Header", str_to_bytes("custom-value")),
]).unwrap()
// Or construct empty and append.
let headers = @types.Fields::fields()
let _ = headers.append("Authorization", str_to_bytes("Bearer my-token"))
let _ = headers.append("Content-Type", str_to_bytes("application/json"))Reading Response Headers
let response = future_response.get().unwrap().unwrap().unwrap()
let _status : UInt = response.status()
let resp_headers = response.headers()
// Fields::get returns Array[FixedArray[Byte]] (a single header may have multiple values).
let content_type_values = resp_headers.get("Content-Type")
let content_type : String? = match content_type_values.get(0) {
Some(v) => Some(bytes_to_str(v))
None => None
}Setting Timeouts
RequestOptions is a resource constructed via request_options(). Timeouts are UInt64 nanoseconds:
let options = @types.RequestOptions::request_options()
let _ = options.set_connect_timeout(Some(5_000_000_000UL)) // 5 s
let _ = options.set_first_byte_timeout(Some(10_000_000_000UL)) // 10 s
let _ = options.set_between_bytes_timeout(Some(30_000_000_000UL)) // 30 s
let future_response = @http.handle(request, Some(options)).unwrap()Retrying on non-2xx Responses
@http.handle resolves successfully when the server returns an HTTP response, even if the status
is 500. From Golem’s durability point of view, the recorded oplog entry already contains the
500 response, and any subsequent application-level error you produce based on the status is a
separate failure after the side effect.
There are two ways to make Golem retry the HTTP call itself when a non-2xx response arrives.
Preferred: a status-code-keyed retry policy
Define a named retry policy whose predicate explicitly references status-code. When the
response arrives the host transparently re-sends the request — no atomic block and no
application-level error needed. The resolved response is the last attempt. See the
golem-retry-policies-moonbit skill for the policy YAML / SDK shape.
POST/PUT/PATCH work out of the box. Idempotence mode defaults to
true, so a plainPOST /chargewith a body is already eligible for status-code retry — there is no need to wrap the call in@api.with_idempotence_mode(true, ...). Seegolem-atomic-block-moonbitfor details on when (rarely) to opt out with@api.with_idempotence_mode(false, ...).
The policy is defined once (in golem.yaml or via the SDK) and the call site is just a plain
HTTP call — no extra wrapping is needed at the call site:
# golem.yaml — under retryPolicyDefaults / <env>:
http-5xx-retry:
priority: 20
predicate:
propIn: { property: "status-code", values: [500, 502, 503, 504] }
policy:
countBox:
maxRetries: 3
inner:
exponential:
baseDelay: "200ms"
factor: 2.0If status retry “doesn’t seem to work”, See the golem-local-dev-server skill and look for the
HTTP status retry skipped, reason: … debug line — it pinpoints why a particular request was
not retried (e.g. BodyNotFinished, NotIdempotent, NoRetry).
Fallback: atomic block
When the retry trigger is application-level (e.g. retrying based on a parsed body field, or
combining multiple side effects into one logical step), wrap the entire request,
response-body read, and status check in an atomic block. The failed atomic block is not
committed, so recovery re-executes the request instead of replaying the recorded failed
response. See golem-atomic-block-moonbit.
Error Handling
Both @http.handle and FutureIncomingResponse::get can fail. get() returns Option[Result[Result[IncomingResponse, ErrorCode], Unit]] — None means not ready yet, the inner Err(()) means the response was already consumed:
///|
fn fetch_data(authority : String, path : String) -> Result[String, String] {
let headers = @types.Fields::fields()
let request = @types.OutgoingRequest::outgoing_request(headers)
let _ = request.set_scheme(Some(@types.Https))
let _ = request.set_authority(Some(authority))
let _ = request.set_path_with_query(Some(path))
let body = request.body().unwrap()
@types.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()
let _ = @types.IncomingBody::finish(incoming_body)
let body_str = bytes_to_str(bytes)
if status >= 200 && status < 300 {
Ok(body_str)
} else {
Err("HTTP " + status.to_string() + ": " + body_str)
}
}
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
InputStream::blocking_read returns up to the requested number of bytes and returns Err(@streams.Closed) when EOF has been reached. Read in a loop for arbitrary-size bodies:
///|
fn read_full_body(incoming_body : @types.IncomingBody) -> FixedArray[Byte] {
let stream = incoming_body.stream().unwrap()
let chunks : Array[FixedArray[Byte]] = []
let mut done = false
while not(done) {
match stream.blocking_read(65536UL) {
Ok(chunk) =>
if chunk.length() == 0 {
done = true
} else {
chunks.push(chunk)
}
Err(@streams.Closed) => done = true
Err(e) => abort("stream read failed: " + e.to_string())
}
}
stream.drop()
let _ = @types.IncomingBody::finish(incoming_body)
// Concatenate.
let total = chunks.iter().fold(init=0, fn(acc, c) { acc + c.length() })
let result : FixedArray[Byte] = 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 = @types.Fields::from_list([
("Accept", str_to_bytes("application/json")),
]).unwrap()
let request = @types.OutgoingRequest::outgoing_request(headers)
let _ = request.set_scheme(Some(@types.Https))
let _ = request.set_authority(Some(self.base_url))
let _ = request.set_path_with_query(Some(path))
let body = request.body().unwrap()
@types.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()
let _ = @types.IncomingBody::finish(incoming_body)
let result = bytes_to_str(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 (
@types.OutgoingBody::finish(body, None)), 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], notBytes—b"..."literals areBytesand do not type-check againstFields::append/Fields::from_list. Convert with@utf8.encode(s).to_fixedarray()(orb"...".to_fixedarray()for ASCII literals). String::to_bytes()returns UTF-16LE bytes — use@encoding/utf8.encodefor HTTP, neverString::to_bytes().String::from_utf8_lossydoes not exist — use@utf8.decode_lossy(Bytes::from_fixedarray(arr).view()).- The constructor for
OutgoingRequestisoutgoing_request(fields); forFieldsit isfields(); forRequestOptionsit isrequest_options(). There is no::newon any of these. MethodandSchemevariant constructors live in@types, not@http(@httponly re-exports the type aliases). Use@types.Post,@types.Https, etc.request.method_()(with trailing underscore) is the getter —methodis a reserved keyword.blocking_readreads up to the requested number of bytes — for large responses, read in a loop until you observe an empty chunk orErr(@streams.Closed).- HTTP requests are automatically durably persisted by Golem — no manual durability wrapping is needed.
Methoddefaults toGetwhen constructing anOutgoingRequest; only callset_methodfor non-GET requests.