Skip to content

Error Handling

Proper error handling ensures your components are reliable and debuggable. TinySystems distinguishes between transient errors (retry) and permanent errors (don't retry).

Return Values

The Handle() method returns any:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    // Success
    return nil

    // Error (will be retried)
    return fmt.Errorf("something went wrong")

    // Permanent error (won't be retried)
    return errors.PermanentError(fmt.Errorf("invalid input"))
}

Error Types

Transient Errors

Regular Go errors are considered transient and will be retried:

go
import "fmt"

func (c *HTTPClient) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    resp, err := http.Get(url)
    if err != nil {
        // Network error - retry makes sense
        return fmt.Errorf("http request failed: %w", err)
    }

    if resp.StatusCode >= 500 {
        // Server error - might recover
        return fmt.Errorf("server error: %d", resp.StatusCode)
    }

    return nil
}

Retry behavior:

  • Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s max
  • Retries continue until success or context cancellation

Permanent Errors

Use errors.PermanentError() for errors that should not be retried:

go
import "github.com/tiny-systems/module/pkg/errors"

func (c *Validator) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    input := msg.(Input)

    // Validation error - retrying won't help
    if input.Email == "" {
        return errors.PermanentError(fmt.Errorf("email is required"))
    }

    // Business logic error - won't change on retry
    if !isValidEmail(input.Email) {
        return errors.PermanentError(fmt.Errorf("invalid email format: %s", input.Email))
    }

    return nil
}

Checking Error Type

go
import "github.com/tiny-systems/module/pkg/errors"

err := someOperation()
if errors.IsPermanent(err) {
    // Don't retry, log and move on
    log.Error("permanent error", "error", err)
} else {
    // Transient, will be retried
    return err
}

Error Propagation

Errors propagate back through the call chain:

Node A                    Node B                    Node C
  │                         │                         │
  │ output() ───────────────│──────────────────────▶ │
  │                         │                         │ returns error
  │ ◀───────────────────────│◀────────────────────────│
  │                         │ receives error          │
  │ receives error          │                         │
  │                         │                         │
go
// Node A
func (c *NodeA) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    err := output(ctx, "output", msg)
    if err != nil {
        // Error from Node B or C
        log.Error("downstream failed", "error", err)
        return err  // Propagate up
    }
    return nil
}

Error Handling Patterns

Pattern 1: Try/Catch Style

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    result, err := riskyOperation(msg)
    if err != nil {
        // Send to error port
        return output(ctx, "error", ErrorOutput{
            Message: err.Error(),
            Input:   msg,
        })
    }

    // Send to success port
    return output(ctx, "success", result)
}

Pattern 2: Error Port

Define separate ports for success and error:

go
func (c *Component) Ports() []module.Port {
    return []module.Port{
        {Name: "input", Position: module.Left},
        {Name: "success", Position: module.Right, Source: true},
        {Name: "error", Position: module.Bottom, Source: true},
    }
}

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    result, err := process(msg)
    if err != nil {
        return output(ctx, "error", ErrorMessage{Error: err.Error()})
    }
    return output(ctx, "success", result)
}

Pattern 3: Wrap and Enrich

Add context to errors:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    input := msg.(Input)

    result, err := externalService.Call(input.ID)
    if err != nil {
        return 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:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    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:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    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
        }
        output(ctx, "success", item)
    }

    if len(errs) > 0 {
        // Report errors but don't fail completely
        output(ctx, "errors", ErrorSummary{Errors: errs})
    }

    return nil
}

Context Errors

Always respect context cancellation:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    for _, item := range items {
        // Check for cancellation
        select {
        case <-ctx.Done():
            return ctx.Err()  // context.Canceled or context.DeadlineExceeded
        default:
        }

        process(item)
    }
    return nil
}

Type Assertion Errors

Handle type mismatches gracefully:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    input, ok := msg.(Input)
    if !ok {
        // Permanent error - wrong type won't fix itself
        return errors.PermanentError(fmt.Errorf("expected Input, got %T", msg))
    }

    return nil
}

Error Logging

Use structured logging for debugging:

go
import "github.com/rs/zerolog/log"

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    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 err
    }

    return nil
}

Error in Async Operations

Handle errors in goroutines:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) any {
    go func() {
        asyncCtx := trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(ctx))

        result, err := longRunningOperation(msg)
        if err != nil {
            // Can't propagate error - log it
            log.Error().Err(err).Msg("async operation failed")

            // Optionally send to error port
            output(asyncCtx, "error", ErrorMessage{Error: err.Error()})
            return
        }

        output(asyncCtx, "output", result)
    }()

    return nil  // Main handler returns immediately
}

Best Practices

1. Use Permanent Errors Appropriately

go
// Use for validation/business logic errors
return errors.PermanentError(fmt.Errorf("invalid input"))

// Don't use for network/temporary errors
return fmt.Errorf("connection timeout")  // Will retry

2. Include Context in Errors

go
// Good: Includes context
return fmt.Errorf("failed to fetch user %s: %w", userID, err)

// Bad: No context
return err

3. Don't Swallow Errors

go
// Bad: Error lost
result, _ := riskyOperation()

// Good: Handle or propagate
result, err := riskyOperation()
if err != nil {
    return err
}

4. Use Error Ports for Expected Failures

go
// For expected error cases, use separate ports
{Name: "error", Position: module.Bottom, Source: true}

// Return actual errors only for unexpected failures
return fmt.Errorf("unexpected: %w", err)

Next Steps

Build flow-based applications on Kubernetes