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:

  1. Asks module.ShouldRetry(err). Unmarked or permanent errors get a single shot — no retry, however the edge is configured.
  2. For marked-transient failures, re-dispatches with exponential backoff, up to 3 attempts by default.
  3. 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:

FieldDefaultMeaning
maxAttempts3 (1–10 allowed)Total dispatch attempts for marked-transient failures
initialDelayMs1000Backoff before the second attempt
backoffCoefficient"2.0"Multiplier per attempt (exponential)
maxDelayMs30000Cap on a single attempt's delay
nonRetryableErrorCodesCodes that short-circuit retry (e.g. "quota_exceeded", "unauthorized")
timeoutMstransport defaultPer-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