Handler Interface Reference

Complete reference for the message handler function used in TinySystems components.

Source of truth: module/handler.go, module/result.go, module/errors.go in the SDK (github.com/tiny-systems/module).

Handler Definition

type Handler func(ctx context.Context, port string, data any) module.Result

The Handler is the function components call to emit data to one of their own ports (or system ports). The framework injects an instance as the second argument of Component.Handle, and again — for long-lived goroutines — via EmitterAware.OnEmitter.

Returning Result rather than any is deliberate: the return must propagate up through every blocking-I/O caller (http-server is the canonical example), and an any return was trivial to silently drop.

Parameters

ctx (context.Context)

The context carries request-scoped values, cancellation signals, and tracing information.

Always pass through the incoming context:

// Correct
output(ctx, "output", result)

// Incorrect - loses tracing
output(context.Background(), "output", result)

Context Values:

FunctionDescription
utils.IsLeader(ctx)Check if current instance is leader (pkg/utils)
trace.SpanFromContext(ctx)Get current tracing span
ctx.Done()Channel closed on cancellation
ctx.Err()Error if context cancelled/expired

port (string)

The name of the output port to emit the message on.

Requirements:

  • Must match a port defined in Ports() with Source: true (an output port), or be a system port like v1alpha1.ReconcilePort
  • Case-sensitive

Examples:

output(ctx, "output", data)
output(ctx, "result", result)
output(ctx, "error", module.NewError(reqContext, err))

data (any)

The data to emit. Should match the port's schema.

Type Requirements:

  • Must be JSON-serializable
  • Should match the Configuration struct for the port

Examples:

// Struct matching the port's Configuration type (preferred)
output(ctx, "output", Output{
    Result: "processed",
    Count:  42,
})

// Canonical error-port payload
output(ctx, "error", module.NewError(reqContext, err))

Return Value: module.Result

Every Handler call and every Component.Handle invocation returns module.Result. It carries either a successful payload, an error, or nothing (a successful no-op).

// Constructors
module.Ok(v any) Result     // successful payload (v may be nil)
module.Fail(err error) Result // failure; Fail(nil) is a zero (success) Result

// Accessors
r.Err() error   // wrapped error, or nil on success
r.Value() any   // wrapped payload, or nil on failure
r.IsErr() bool  // true when the result wraps an error
StateMeaning
Err() == nilDelivered successfully; Value() may carry a downstream response
Err() != nilDelivery or downstream processing failed
zero ResultValid "successful no-op"

Error Handling:

res := output(ctx, "output", result)
if err := res.Err(); err != nil {
    return module.Fail(err)
}
return module.Ok(nil)

The idiomatic short form chains the result directly:

return output(ctx, "output", result)

Retryability Contract

Retryability is a property of the error, not of the caller. Unmarked errors are never retried.

APIPackagePurpose
module.Retryable(err)moduleMark a transient failure (5xx, 429, timeout) safe to retry
module.Permanent(err)moduleExplicitly mark not-retryable (optional; plain errors already default to not-retryable)
module.IsRetryable(err)moduleRead the marking anywhere in the wrap chain
module.ShouldRetry(err)moduleThe single predicate every retry layer consults (edge retry policies and the retry component)
errors.NewPermanentError(err)pkg/errorsLegacy permanent marker; still honoured — a permanent marking wins over any retryable one
errors.NonRetryable(code, err)pkg/errorsPermanent + stable string code, matched against an edge's nonRetryableErrorCodes
if resp.StatusCode >= 500 {
    return module.Fail(module.Retryable(fmt.Errorf("upstream 5xx: %s", body)))
}

Error ports should emit the canonical module.ErrorMessage shape, built with module.NewError, which derives Retryable from the error itself:

type ErrorMessage struct {
    Context   any    `json:"context,omitempty"` // original request, for recovery/retry flows
    Error     string `json:"error"`
    Retryable bool   `json:"retryable"`
}

return output(ctx, ErrorPort, module.NewError(reqContext, err))

Blocking Behavior

Default: Blocking

The handler blocks until all downstream processing completes:

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    result := process(msg)

    // Blocks until downstream nodes finish; carries back their response
    return output(ctx, "output", result)
}

Flow:

output() called
    |
    +-> Message delivered to next node
    |       |
    |       +-> Next node processes
    |       |       |
    |       |       +-> (continues downstream)
    |       |
    |       +-> Returns when complete
    |
    +-> output() returns Result (response or error)

Benefits of Blocking

  1. Backpressure: Automatically limits processing rate
  2. Resource Management: Prevents unbounded queue growth
  3. Error Propagation: Errors return to source
  4. Request/Response: Synchronous responses flow back to blocking-I/O components (http-server)

Nodes labeled with tinysystems.io/execution-mode: durable behave differently: downstream emits are published fire-and-forget to the JetStream work queue with an idempotency key instead of blocking for the subtree's response.


Emitting from Goroutines

For background loops (tickers, cron, watchers), do not capture the Handle argument. Implement EmitterAware (or embed module.Base) to receive a long-lived Handler:

type EmitterAware interface {
    OnEmitter(emit module.Handler)
}
// With module.Base embedded:
func (t *Component) tick(ctx context.Context) {
    _ = t.Emit(ctx, "out", Context{Time: time.Now().Unix()})
}

The injected Handler stays valid for the runner's lifetime; calling it after the component is destroyed is a no-op routed through cancelled contexts.


Multiple Outputs

Sequential Outputs

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    if res := output(ctx, "processed", result1); res.Err() != nil {
        return res
    }
    // Second output (after first completes)
    return output(ctx, "logged", result2)
}

Conditional Output

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    in, ok := msg.(Input)
    if !ok {
        return module.Fail(fmt.Errorf("invalid input type: %T", msg))
    }

    if in.Valid {
        return output(ctx, "output", in)
    }
    return output(ctx, "error", module.NewError(in, errors.New("invalid input")))
}

System Port Emission

Reconcile Port

Emit an updater function to patch the node. The updater signature is func(node *v1alpha1.TinyNode) error:

output(ctx, v1alpha1.ReconcilePort, func(n *v1alpha1.TinyNode) error {
    if n.Status.Metadata == nil {
        n.Status.Metadata = make(map[string]string)
    }
    n.Status.Metadata["my-key"] = "my-value"
    return nil
})

The patch is debounced and applied through the reconcile-port. Prefer the module.State interface (via Stateful or Base) for plain key-value persistence — it wraps this mechanism.

Control Port

Emitting to v1alpha1.ControlPort refreshes the node's control/widget state in the UI. Incoming control messages are received via ControlHandler.OnControl(ctx, control any) error — not via Handle.


Best Practices

1. Never Drop the Result

// Good
return output(ctx, "output", result)

// Bad — response and error silently lost
_ = output(ctx, "output", result)
return module.Ok(nil)

2. Pass Context Through

// Good
output(ctx, "output", result)

// Bad - loses tracing/cancellation
output(context.Background(), "output", result)

3. Mark Transient Failures

// Good — edge retry policies and the retry component can act on it
return module.Fail(module.Retryable(err))

// Default — unmarked errors are NOT retried
return module.Fail(err)

4. Match Schema Types

// Good - matches port schema
output(ctx, "output", Output{Result: value})

// Risky - may not match schema
output(ctx, "output", map[string]any{"result": value})

5. Use the Canonical Error Shape

// Good — {context, error, retryable}, understood by the retry component
output(ctx, "error", module.NewError(reqContext, err))

// Avoid hand-rolled error structs