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 retry2. Include Context in Errors
go
// Good: Includes context
return fmt.Errorf("failed to fetch user %s: %w", userID, err)
// Bad: No context
return err3. 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
- Component Patterns - See error handling in practice
- Testing Components - Test error scenarios
- Observability - Track errors with tracing