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
- TLS Introspection: surface certificate expiry from
resp.TLS(the shippedhttp_requestdoes this) - Response Streaming: chunked handling for large bodies
- Request Signing: AWS Signature, HMAC signing
- Connection Pooling: share a tuned
http.Transportacross calls