HTTP Client Component

A complete example of an HTTP client component for making external API calls, modeled on the http_request component from http-module.

Overview

This component makes HTTP requests to external services. It demonstrates:

  • Blocking request/response inside Handle
  • An optional error port gated by settings
  • Retryability marking with module.Retryable
  • The canonical {context, error, retryable} error shape

Complete Implementation

package client

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/tiny-systems/module/api/v1alpha1"
    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/registry"
)

const (
    ComponentName = "http_client"
    RequestPort   = "request"
    ResponsePort  = "response"
    ErrorPort     = "error"
)

type Context any

type Header struct {
    Key   string `json:"key" required:"true" title:"Key"`
    Value string `json:"value" required:"true" title:"Value"`
}

type Settings struct {
    EnableErrorPort bool `json:"enableErrorPort" required:"true" title:"Enable Error Port" description:"If the request fails (or returns status >= 400), emit an error message on the error port instead of failing the flow."`
}

// Request is the input shape. Everything is mappable from upstream data
// via edge expressions (configurable context) or set as literals.
type Request struct {
    Context     Context  `json:"context,omitempty" configurable:"true" title:"Context" description:"Message to be sent further"`
    Method      string   `json:"method" required:"true" title:"Method" enum:"GET,POST,PATCH,PUT,DELETE" enumTitles:"GET,POST,PATCH,PUT,DELETE"`
    Timeout     int      `json:"timeout" required:"true" title:"Request Timeout" description:"Seconds"`
    URL         string   `json:"url" required:"true" title:"URL" format:"uri"`
    Headers     []Header `json:"headers,omitempty" title:"Headers"`
    ContentType string   `json:"contentType" required:"true" title:"Request Content Type"`
    Body        string   `json:"body" title:"Request Body" format:"textarea"`
}

type ResponseData struct {
    Headers    []Header `json:"headers" title:"Headers"`
    Status     string   `json:"status"`
    StatusCode int      `json:"statusCode"`
    Body       string   `json:"body" title:"Body"`
}

type Response struct {
    Context  Context      `json:"context" configurable:"true" title:"Context" description:"Passthrough from the request"`
    Response ResponseData `json:"response" title:"Response" description:"HTTP Response"`
}

// Error mirrors the canonical module.ErrorMessage contract
// {context, error, retryable}, extended with the raw response.
type Error struct {
    Context   Context      `json:"context" configurable:"true" title:"Context"`
    Error     string       `json:"error" required:"true"`
    Retryable bool         `json:"retryable" title:"Retryable" description:"True for network failures, 429 and 5xx — wire the error port into the retry component to retry these with backoff."`
    Response  ResponseData `json:"response"`
}

type Component struct {
    settings Settings
}

func (h *Component) Instance() module.Component {
    return &Component{}
}

func (h *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        ComponentName,
        Description: "HTTP Client",
        Info:        "Outbound HTTP request maker. Blocks until the HTTP response is received. On success (status < 400): emits context + response on the Response port. On failure or status >= 400: fails the message, or emits on the Error port when enableErrorPort=true.",
        Tags:        []string{"HTTP", "Client"},
    }
}

// OnSettings receives Settings from the SettingsPort.
func (h *Component) OnSettings(_ context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }
    h.settings = in
    return nil
}

// Handle dispatches the request port. System ports go through capabilities.
func (h *Component) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
    if port != RequestPort {
        return module.Fail(fmt.Errorf("port %s is not supported", port))
    }
    in, ok := msg.(Request)
    if !ok {
        return module.Fail(fmt.Errorf("invalid message"))
    }
    return h.doRequest(ctx, handler, in)
}

func (h *Component) doRequest(ctx context.Context, handler module.Handler, in Request) module.Result {
    ctx, cancel := context.WithTimeout(ctx, time.Second*time.Duration(in.Timeout))
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, in.Method, in.URL, bytes.NewReader([]byte(in.Body)))
    if err != nil {
        // Malformed request — permanent, will not improve on retry
        return h.handleError(ctx, handler, in.Context, err, ResponseData{})
    }

    if in.ContentType != "" {
        req.Header.Set("Content-Type", in.ContentType)
    }
    for _, header := range in.Headers {
        req.Header.Set(header.Key, header.Value)
    }

    resp, err := (&http.Client{}).Do(req)
    if err != nil {
        // No response at all (DNS/dial/timeout) — transient, mark retryable
        return h.handleError(ctx, handler, in.Context, module.Retryable(err), ResponseData{})
    }
    defer resp.Body.Close()

    b, err := io.ReadAll(resp.Body)
    if err != nil {
        return h.handleError(ctx, handler, in.Context, module.Retryable(err), ResponseData{})
    }

    var headers []Header
    for k, v := range resp.Header {
        for _, vv := range v {
            headers = append(headers, Header{Key: k, Value: vv})
        }
    }

    respData := ResponseData{
        Body:       string(b),
        Headers:    headers,
        Status:     resp.Status,
        StatusCode: resp.StatusCode,
    }

    if resp.StatusCode >= 400 {
        statusErr := fmt.Errorf("%s", respData.Body)
        // 429 and 5xx can clear with backoff; 4xx is the caller's fault
        if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
            statusErr = module.Retryable(statusErr)
        }
        return h.handleError(ctx, handler, in.Context, statusErr, respData)
    }

    return handler(ctx, ResponsePort, Response{
        Response: respData,
        Context:  in.Context,
    })
}

func (h *Component) handleError(ctx context.Context, handler module.Handler, reqContext Context, err error, resp ResponseData) module.Result {
    if !h.settings.EnableErrorPort {
        // Bubble the error unchanged — retryability markers ride along
        // through Result.Err so upstream layers see them.
        return module.Fail(err)
    }
    // Read retryability back off the error itself
    return handler(ctx, ErrorPort, Error{
        Context:   reqContext,
        Error:     err.Error(),
        Retryable: module.IsRetryable(err),
        Response:  resp,
    })
}

func (h *Component) Ports() []module.Port {
    ports := []module.Port{
        {
            Name:  RequestPort,
            Label: "Request",
            Configuration: Request{
                Method:      http.MethodGet,
                Headers:     make([]Header, 0),
                Timeout:     10,
                ContentType: "application/json",
            },
            Position: module.Left,
        },
        {
            Name:          v1alpha1.SettingsPort,
            Label:         "Settings",
            Configuration: h.settings,
        },
        {
            Name:          ResponsePort,
            Label:         "Response",
            Source:        true,
            Configuration: new(Response),
            Position:      module.Right,
        },
    }
    if h.settings.EnableErrorPort {
        ports = append(ports, module.Port{
            Name:          ErrorPort,
            Label:         "Error",
            Source:        true,
            Configuration: new(Error),
            Position:      module.Bottom,
        })
    }
    return ports
}

var (
    _ module.Component       = (*Component)(nil)
    _ module.SettingsHandler = (*Component)(nil)
)

func init() {
    registry.Register((&Component{}).Instance())
}

Usage Examples

Requests are configured on the incoming edge; expressions map upstream data into the request fields.

Basic GET Request

{
  "method": "GET",
  "timeout": 30,
  "url": "https://api.example.com/users/{{$.context.userId}}",
  "contentType": "application/json"
}

POST with JSON Body

{
  "method": "POST",
  "timeout": 10,
  "url": "https://api.example.com/users",
  "contentType": "application/json",
  "body": "{\"name\": \"{{$.context.name}}\", \"email\": \"{{$.context.email}}\"}"
}

Authenticated Request with a Secret

Secrets are referenced with [[secret:<name>/<key>]] placeholders in edge configuration — never hardcode tokens:

{
  "method": "GET",
  "timeout": 30,
  "url": "https://api.github.com/repos/{{$.context.owner}}/{{$.context.repo}}",
  "contentType": "application/json",
  "headers": [
    { "key": "Authorization", "value": "Bearer [[secret:github/token]]" },
    { "key": "Accept", "value": "application/vnd.github.v3+json" }
  ]
}

Response Handling

Success Response (on response)

{
  "context": { "userId": "42" },
  "response": {
    "statusCode": 200,
    "status": "200 OK",
    "headers": [ { "key": "Content-Type", "value": "application/json" } ],
    "body": "{\"users\":[{\"id\":1,\"name\":\"John\"}]}"
  }
}

Downstream edges read $.response.body, $.response.statusCode, and the passthrough $.context....

Error Response (on error, when enabled)

{
  "context": { "userId": "42" },
  "error": "connection refused",
  "retryable": true,
  "response": { "statusCode": 0, "headers": null, "body": "" }
}

Key Patterns Demonstrated

1. No Built-In Retry Loop

The component performs exactly one attempt. Retrying is a flow concern: wire the error port into the retry component (common-module), which honors the retryable flag, applies bounded backoff, and loops back into the request port:

http_client.request ◄──────────────┐
      │                            │
      ├─ response → rest of flow   │
      └─ error ───→ retry.request  │
                       ├─ retry ───┘  (sleeps, then re-invokes)
                       └─ failed → dead-letter / alert

2. Marking Retryability at the Failure Site

// transport failure — never reached the server
return h.handleError(ctx, handler, in.Context, module.Retryable(err), ResponseData{})

// 429 / 5xx — backoff can clear it
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
    statusErr = module.Retryable(statusErr)
}

Unmarked errors are treated as permanent — nothing in the platform retries them.

3. Error Port Gated by Settings

Ports() only declares the error port when enableErrorPort is true, so the node's visual shape matches its behavior. Without it, failures bubble up as module.Fail(err) to the calling edge.

4. Blocking by Design

Handle blocks until the HTTP response arrives, and the emitted Result propagates back to whatever upstream is waiting (e.g. an http_server holding a live connection). No goroutines, no callbacks.

Extension Ideas

  1. TLS Introspection: surface certificate expiry from resp.TLS (the shipped http_request does this)
  2. Response Streaming: chunked handling for large bodies
  3. Request Signing: AWS Signature, HMAC signing
  4. Connection Pooling: share a tuned http.Transport across calls