Error Handling
Proper error handling ensures your components are reliable and debuggable. TinySystems distinguishes between transient errors (safe to retry) and permanent errors (never retried) — and the component that produces the error is the one that decides, by marking it.
Return Values
The Handle() method returns module.Result, built with module.Ok / module.Fail:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
// Success (payload optional)
return module.Ok(nil)
// Failure — NOT retried unless you mark it transient
return module.Fail(fmt.Errorf("something went wrong"))
// Transient failure — opted into retry
return module.Fail(module.Retryable(fmt.Errorf("upstream 503")))
// Explicitly permanent — reads clearer at validation branches
return module.Fail(module.Permanent(fmt.Errorf("invalid input")))
}
Inspect a result with res.Err(), res.Value(), res.IsErr(). A zero Result is a successful no-op.
The Retry Contract
Unmarked errors are not retried. This default is deliberate: re-attempting a hop whose side effect already landed duplicates it — an INSERT inserts twice, a paid LLM completion bills twice. A component opts its transient failures into retry with module.Retryable; everything else gets a single shot.
Marking Transient Errors
Wrap failures that a backoff retry could clear (5xx, 429, dropped connections, timeouts):
func (c *HTTPClient) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
resp, err := http.Get(url)
if err != nil {
// Network error — retry makes sense, mark it
return module.Fail(module.Retryable(fmt.Errorf("http request failed: %w", err)))
}
if resp.StatusCode >= 500 {
// Server error — might recover
return module.Fail(module.Retryable(fmt.Errorf("server error: %d", resp.StatusCode)))
}
return module.Ok(nil)
}
The marking survives wrapping and bubbling through Fail/Result.Err, so the layer that retries sees the decision made by the code that understood the failure.
Marking Permanent Errors
A plain error already defaults to not-retryable, but module.Permanent makes the decision explicit at validation and business-logic branches:
func (c *Validator) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
// Validation error — retrying won't help
if input.Email == "" {
return module.Fail(module.Permanent(fmt.Errorf("email is required")))
}
if !isValidEmail(input.Email) {
return module.Fail(module.Permanent(fmt.Errorf("invalid email format: %s", input.Email)))
}
return module.Ok(nil)
}
Checking Retryability
module.ShouldRetry is the single predicate every retry layer consults — the scheduler's edge dispatch and the retry component alike:
err := someOperation()
if module.ShouldRetry(err) {
// Marked transient — a re-attempt could succeed
} else {
// Unmarked or permanent — surface it, don't retry
}
module.IsRetryable(err) reports the marking itself; ShouldRetry additionally honours permanent markers from the deprecated pkg/errors package (a permanent marking always wins).
How Edges Retry
When a component's handler fails during edge dispatch, the scheduler:
- Asks
module.ShouldRetry(err). Unmarked or permanent errors get a single shot — no retry, however the edge is configured. - For marked-transient failures, re-dispatches with exponential backoff, up to 3 attempts by default.
- Skips retry entirely if the error carries a code listed in the edge's
NonRetryableErrorCodes.
Flow authors tune this per edge with EdgeRetryPolicy on the TinyNodeEdge:
| Field | Default | Meaning |
|---|---|---|
maxAttempts | 3 (1–10 allowed) | Total dispatch attempts for marked-transient failures |
initialDelayMs | 1000 | Backoff before the second attempt |
backoffCoefficient | "2.0" | Multiplier per attempt (exponential) |
maxDelayMs | 30000 | Cap on a single attempt's delay |
nonRetryableErrorCodes | — | Codes that short-circuit retry (e.g. "quota_exceeded", "unauthorized") |
timeoutMs | transport default | Per-attempt handler timeout |
Components signal coded, non-retryable failures with errors.NonRetryable(code, err) from pkg/errors; the transport stamps the code on the reply (x-error-code) and the scheduler matches it against the policy.
Durable Flows
On durable hops (JetStream work queue), the same predicate decides delivery acknowledgment: a handler failure that ShouldRetry approves is Nak-ed for broker redelivery; anything else is Term-ed — single shot, never redelivered.
Deprecated: pkg/errors
pkg/errors (NewPermanentError, IsPermanent) is the old vocabulary. It is still honoured by ShouldRetry for compatibility, and NonRetryable(code, err) remains useful for edge-policy codes, but new code should mark retryability with module.Retryable / module.Permanent.
Error Propagation
On the classic (blocking) path, failures propagate back through the call chain as module.Result:
Node A Node B Node C
| | |
| output() ---------------|----------------------> |
| | | returns Fail(err)
| <-----------------------|<------------------------|
| | receives failure |
| receives failure | |
| | |
// Node A
func (c *NodeA) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
res := output(ctx, "output", msg)
if err := res.Err(); err != nil {
// Failure from Node B or C
log.Error("downstream failed", "error", err)
return res // Propagate up
}
return module.Ok(nil)
}
Error Handling Patterns
Pattern 1: Error Port with the Canonical Payload
For expected failures, emit module.ErrorMessage — the canonical {context, error, retryable} shape the retry component and the platform understand — built with module.NewError:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := riskyOperation(input)
if err != nil {
// NewError derives Retryable from the error's marking and carries
// the original request context so a recovery flow can re-invoke
return output(ctx, "error", module.NewError(input.Context, err))
}
return output(ctx, "success", result)
}
Mark the error with module.Retryable at the point you know it's transient; NewError picks that up so a downstream retry component sees it.
Pattern 2: Separate Success and Error Ports
func (c *Component) Ports() []module.Port {
return []module.Port{
{Name: "input", Position: module.Left, Source: false, Configuration: Input{}},
{Name: "success", Position: module.Right, Source: true, Configuration: Output{}},
{Name: "error", Position: module.Bottom, Source: true, Configuration: module.ErrorMessage{}},
}
}
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := process(input)
if err != nil {
return output(ctx, "error", module.NewError(input.Context, err))
}
return output(ctx, "success", result)
}
Pattern 3: Wrap and Enrich
Add context to errors — wrapping preserves any retryability marking on the inner error:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := externalService.Call(input.ID)
if err != nil {
return module.Fail(fmt.Errorf("failed to process item %s: %w", input.ID, err))
}
return output(ctx, "output", result)
}
Pattern 4: Graceful Degradation
Handle errors without stopping the flow:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := optionalEnrichment(input)
if err != nil {
// Log but continue with unenriched data
log.Warn("enrichment failed, using default", "error", err)
result = defaultResult(input)
}
return output(ctx, "output", result)
}
Pattern 5: Batch Error Handling
Continue processing despite individual failures:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
items := msg.([]Item)
var errs []error
for _, item := range items {
if err := processItem(item); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
continue // Continue with next item
}
if res := output(ctx, "success", item); res.Err() != nil {
return res
}
}
if len(errs) > 0 {
// Report errors but don't fail completely
return output(ctx, "errors", ErrorSummary{Errors: errs})
}
return module.Ok(nil)
}
Context Errors
Always respect context cancellation:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
for _, item := range items {
// Check for cancellation
select {
case <-ctx.Done():
return module.Fail(ctx.Err()) // context.Canceled or context.DeadlineExceeded
default:
}
process(item)
}
return module.Ok(nil)
}
Type Assertion Errors
Handle type mismatches gracefully:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input, ok := msg.(Input)
if !ok {
// Wrong type won't fix itself — mark permanent
return module.Fail(module.Permanent(fmt.Errorf("expected Input, got %T", msg)))
}
return output(ctx, "output", process(input))
}
Error Logging
Use structured logging for debugging:
import "github.com/rs/zerolog/log"
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := process(input)
if err != nil {
log.Error().
Err(err).
Str("inputId", input.ID).
Str("component", "my-component").
Msg("processing failed")
return module.Fail(err)
}
return output(ctx, "output", result)
}
Error in Async Operations
Handle errors in goroutines:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
go func() {
asyncCtx := trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(ctx))
result, err := longRunningOperation(input)
if err != nil {
// Can't propagate the failure — log it
log.Error().Err(err).Msg("async operation failed")
// Optionally send to error port
_ = output(asyncCtx, "error", module.NewError(input.Context, err))
return
}
_ = output(asyncCtx, "output", result)
}()
return module.Ok(nil) // Main handler returns immediately
}
Best Practices
1. Mark Retryability at the Source
// Transient — a backoff retry could clear it
return module.Fail(module.Retryable(fmt.Errorf("connection timeout")))
// Permanent — explicit at validation branches
return module.Fail(module.Permanent(fmt.Errorf("invalid input")))
// Unmarked — single shot, never retried (the safe default)
return module.Fail(fmt.Errorf("unexpected state"))
2. Include Context in Errors
// Good: Includes context
return module.Fail(fmt.Errorf("failed to fetch user %s: %w", userID, err))
// Bad: No context
return module.Fail(err)
3. Don't Swallow Errors or Results
// Bad: Error lost
result, _ := riskyOperation()
// Bad: Result from output() dropped
output(ctx, "output", data)
// Good: Handle or propagate
if res := output(ctx, "output", data); res.Err() != nil {
return res
}
4. Use Error Ports for Expected Failures
// For expected error cases, use a separate port with the canonical payload
{Name: "error", Position: module.Bottom, Source: true, Configuration: module.ErrorMessage{}}
// Return actual failures only for unexpected ones
return module.Fail(fmt.Errorf("unexpected: %w", err))
Next Steps
- Component Patterns - See error handling in practice
- Testing Components - Test error scenarios
- Observability - Track errors with tracing