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:
| Function | Description |
|---|---|
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()withSource: true(an output port), or be a system port likev1alpha1.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
| State | Meaning |
|---|---|
Err() == nil | Delivered successfully; Value() may carry a downstream response |
Err() != nil | Delivery or downstream processing failed |
zero Result | Valid "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.
| API | Package | Purpose |
|---|---|---|
module.Retryable(err) | module | Mark a transient failure (5xx, 429, timeout) safe to retry |
module.Permanent(err) | module | Explicitly mark not-retryable (optional; plain errors already default to not-retryable) |
module.IsRetryable(err) | module | Read the marking anywhere in the wrap chain |
module.ShouldRetry(err) | module | The single predicate every retry layer consults (edge retry policies and the retry component) |
errors.NewPermanentError(err) | pkg/errors | Legacy permanent marker; still honoured — a permanent marking wins over any retryable one |
errors.NonRetryable(code, err) | pkg/errors | Permanent + 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
- Backpressure: Automatically limits processing rate
- Resource Management: Prevents unbounded queue growth
- Error Propagation: Errors return to source
- 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