Welcome to the new Golem Cloud Docs! 👋
Making Outgoing HTTP Requests (MoonBit)

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/json with parameter names as JSON keys.

Resource Lifecycle

WASI HTTP uses resource handles that must be dropped in the correct order:

  1. OutputStream must be dropped before calling OutgoingBody::finish
  2. OutgoingBody must be finished (not just dropped) to signal the body is complete
  3. InputStream must be dropped before calling IncomingBody::finish
  4. 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) are FixedArray[Byte], not strings — use byte literals (b"...") or .to_utf8_bytes()
  • The blocking_read function 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 Method variant defaults to Get when constructing an OutgoingRequest