# Error Handling

> Implement robust error handling and recovery strategies for voice agent resilience.

Source: https://edesy.in/docs/voice-agent/advanced/error-handling

Build resilient voice agents that gracefully handle failures, recover from errors, and provide seamless user experiences.

## Error Categories

```
┌────────────────────────────────────────────────────────────────┐
│                      Error Categories                           │
├──────────────────┬──────────────────┬──────────────────────────┤
│   Recoverable    │   Degradable     │      Fatal               │
├──────────────────┼──────────────────┼──────────────────────────┤
│ • Timeout        │ • TTS failure    │ • Authentication         │
│ • Rate limit     │ • LLM unavailable│ • Invalid config         │
│ • Network glitch │ • STT degraded   │ • WebSocket closed       │
│ • Temporary 500  │ • Tool failure   │ • Call disconnected      │
└──────────────────┴──────────────────┴──────────────────────────┘
```

## Error Types

### Provider Errors

```go
type ProviderError struct {
    Provider  string // "deepgram", "openai", etc.
    Operation string // "transcribe", "generate", etc.
    Code      string
    Message   string
    Retryable bool
    Cause     error
}

func (e *ProviderError) Error() string {
    return fmt.Sprintf("%s %s failed: %s", e.Provider, e.Operation, e.Message)
}

func NewProviderError(provider, op, code, msg string, retryable bool) *ProviderError {
    return &ProviderError{
        Provider:  provider,
        Operation: op,
        Code:      code,
        Message:   msg,
        Retryable: retryable,
    }
}
```

### Pipeline Errors

```go
type PipelineError struct {
    Stage   string // "stt", "llm", "tts", "telephony"
    Error   error
    Context map[string]any
}

func (e *PipelineError) Error() string {
    return fmt.Sprintf("pipeline error at %s: %v", e.Stage, e.Error)
}
```

### Session Errors

```go
type SessionError struct {
    SessionID string
    CallID    string
    Error     error
    Fatal     bool
}
```

## Retry Strategies

### Exponential Backoff

```go
type RetryConfig struct {
    MaxRetries     int
    InitialDelay   time.Duration
    MaxDelay       time.Duration
    BackoffFactor  float64
    RetryableErrors []string
}

var DefaultRetryConfig = RetryConfig{
    MaxRetries:    3,
    InitialDelay:  100 * time.Millisecond,
    MaxDelay:      5 * time.Second,
    BackoffFactor: 2.0,
    RetryableErrors: []string{
        "timeout",
        "rate_limited",
        "server_error",
        "network_error",
    },
}

func (c *RetryConfig) ShouldRetry(err error, attempt int) (bool, time.Duration) {
    if attempt >= c.MaxRetries {
        return false, 0
    }

    if !c.isRetryable(err) {
        return false, 0
    }

    delay := float64(c.InitialDelay) * math.Pow(c.BackoffFactor, float64(attempt))
    if delay > float64(c.MaxDelay) {
        delay = float64(c.MaxDelay)
    }

    // Add jitter
    jitter := rand.Float64() * 0.2 * delay
    return true, time.Duration(delay + jitter)
}

func (c *RetryConfig) isRetryable(err error) bool {
    var provErr *ProviderError
    if errors.As(err, &provErr) {
        return provErr.Retryable
    }

    errStr := err.Error()
    for _, retryable := range c.RetryableErrors {
        if strings.Contains(errStr, retryable) {
            return true
        }
    }
    return false
}
```

### Retry with Context

```go
func RetryWithContext[T any](ctx context.Context, config RetryConfig, fn func() (T, error)) (T, error) {
    var result T
    var lastErr error

    for attempt := 0; attempt <= config.MaxRetries; attempt++ {
        result, lastErr = fn()
        if lastErr == nil {
            return result, nil
        }

        shouldRetry, delay := config.ShouldRetry(lastErr, attempt)
        if !shouldRetry {
            return result, lastErr
        }

        log.Printf("Retry %d/%d after %v: %v", attempt+1, config.MaxRetries, delay, lastErr)

        select {
        case <-ctx.Done():
            return result, ctx.Err()
        case <-time.After(delay):
            continue
        }
    }

    return result, fmt.Errorf("max retries exceeded: %w", lastErr)
}

// Usage
response, err := RetryWithContext(ctx, DefaultRetryConfig, func() (string, error) {
    return llm.Generate(ctx, messages)
})
```

## Circuit Breaker

### Implementation

```go
type CircuitBreaker struct {
    name           string
    maxFailures    int
    resetTimeout   time.Duration
    halfOpenMax    int

    mu            sync.RWMutex
    failures      int
    state         string // closed, open, half-open
    lastFailure   time.Time
    halfOpenCount int
}

func NewCircuitBreaker(name string, maxFailures int, resetTimeout time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        name:         name,
        maxFailures:  maxFailures,
        resetTimeout: resetTimeout,
        halfOpenMax:  1,
        state:        "closed",
    }
}

func (cb *CircuitBreaker) Execute(fn func() error) error {
    if !cb.canExecute() {
        return fmt.Errorf("circuit breaker %s is open", cb.name)
    }

    err := fn()

    cb.mu.Lock()
    defer cb.mu.Unlock()

    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()

        if cb.failures >= cb.maxFailures {
            cb.state = "open"
            log.Printf("Circuit breaker %s opened after %d failures", cb.name, cb.failures)
        }
        return err
    }

    // Success
    if cb.state == "half-open" {
        cb.state = "closed"
        cb.failures = 0
        log.Printf("Circuit breaker %s closed", cb.name)
    }

    return nil
}

func (cb *CircuitBreaker) canExecute() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    switch cb.state {
    case "closed":
        return true

    case "open":
        if time.Since(cb.lastFailure) > cb.resetTimeout {
            cb.state = "half-open"
            cb.halfOpenCount = 0
            return true
        }
        return false

    case "half-open":
        if cb.halfOpenCount < cb.halfOpenMax {
            cb.halfOpenCount++
            return true
        }
        return false
    }

    return false
}
```

### Provider Circuit Breakers

```go
type ProviderCircuitBreakers struct {
    stt map[string]*CircuitBreaker
    tts map[string]*CircuitBreaker
    llm map[string]*CircuitBreaker
}

func NewProviderCircuitBreakers() *ProviderCircuitBreakers {
    return &ProviderCircuitBreakers{
        stt: map[string]*CircuitBreaker{
            "deepgram": NewCircuitBreaker("deepgram-stt", 5, 30*time.Second),
            "google":   NewCircuitBreaker("google-stt", 5, 30*time.Second),
        },
        tts: map[string]*CircuitBreaker{
            "cartesia":   NewCircuitBreaker("cartesia-tts", 5, 30*time.Second),
            "elevenlabs": NewCircuitBreaker("elevenlabs-tts", 5, 30*time.Second),
        },
        llm: map[string]*CircuitBreaker{
            "gemini": NewCircuitBreaker("gemini-llm", 3, 60*time.Second),
            "openai": NewCircuitBreaker("openai-llm", 3, 60*time.Second),
        },
    }
}
```

## Fallback Strategies

### Provider Fallbacks

```go
type FallbackChain struct {
    providers []Provider
    current   int
}

func (f *FallbackChain) Execute(ctx context.Context, fn func(Provider) error) error {
    for i := f.current; i < len(f.providers); i++ {
        err := fn(f.providers[i])
        if err == nil {
            f.current = i // Stick with working provider
            return nil
        }

        log.Printf("Provider %s failed, trying fallback: %v",
            f.providers[i].Name(), err)
    }

    return fmt.Errorf("all providers failed")
}

// Usage
sttFallback := &FallbackChain{
    providers: []Provider{
        deepgramSTT,
        googleSTT,
        azureSTT,
    },
}

err := sttFallback.Execute(ctx, func(p Provider) error {
    stt := p.(STTProvider)
    return stt.Transcribe(ctx, audio)
})
```

### Graceful Degradation

```go
type DegradationStrategy struct {
    levels []DegradationLevel
    current int
}

type DegradationLevel struct {
    Name        string
    LLMProvider string
    TTSProvider string
    Features    []string // Enabled features
}

var DegradationLevels = []DegradationLevel{
    {
        Name:        "full",
        LLMProvider: "gemini-2.5",
        TTSProvider: "cartesia",
        Features:    []string{"function_calling", "interruptions", "caching"},
    },
    {
        Name:        "reduced",
        LLMProvider: "gemini",
        TTSProvider: "google",
        Features:    []string{"interruptions"},
    },
    {
        Name:        "minimal",
        LLMProvider: "gpt-4o-mini",
        TTSProvider: "openai",
        Features:    []string{},
    },
}

func (d *DegradationStrategy) Degrade() {
    if d.current < len(d.levels)-1 {
        d.current++
        log.Printf("Degraded to level: %s", d.levels[d.current].Name)
    }
}
```

## User-Facing Errors

### Error Messages

```go
var UserFriendlyMessages = map[string]string{
    "timeout":           "I'm having a little trouble. Could you please repeat that?",
    "stt_failed":        "I couldn't quite catch that. Can you say it again?",
    "llm_failed":        "I need a moment to think. Please hold on.",
    "tts_failed":        "I apologize, I'm having technical difficulties.",
    "function_failed":   "I couldn't complete that action. Let me try something else.",
    "network_error":     "We seem to have a connection issue. Please wait a moment.",
    "unknown":           "I apologize, something went wrong. Let me try again.",
}

func GetUserMessage(err error) string {
    var provErr *ProviderError
    if errors.As(err, &provErr) {
        if msg, ok := UserFriendlyMessages[provErr.Code]; ok {
            return msg
        }
    }

    return UserFriendlyMessages["unknown"]
}
```

### Speak Error to User

```go
func (p *Pipeline) handleError(err error) {
    // Log full error internally
    log.Printf("Error: %v", err)

    // Speak user-friendly message
    message := GetUserMessage(err)
    p.tts.Speak(message)

    // Attempt recovery
    p.attemptRecovery(err)
}

func (p *Pipeline) attemptRecovery(err error) {
    var provErr *ProviderError
    if errors.As(err, &provErr) {
        switch provErr.Provider {
        case "stt":
            p.resetSTT()
        case "tts":
            p.switchTTSProvider()
        case "llm":
            p.degradeLLM()
        }
    }
}
```

## Connection Recovery

### WebSocket Reconnection

```go
type ConnectionManager struct {
    url            string
    conn           *websocket.Conn
    reconnectDelay time.Duration
    maxReconnects  int
    reconnects     int
    mu             sync.Mutex
}

func (m *ConnectionManager) Reconnect(ctx context.Context) error {
    m.mu.Lock()
    defer m.mu.Unlock()

    if m.reconnects >= m.maxReconnects {
        return fmt.Errorf("max reconnection attempts exceeded")
    }

    for m.reconnects < m.maxReconnects {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(m.reconnectDelay):
        }

        conn, _, err := websocket.DefaultDialer.DialContext(ctx, m.url, nil)
        if err == nil {
            m.conn = conn
            m.reconnects = 0 // Reset on success
            log.Printf("Reconnected successfully")
            return nil
        }

        m.reconnects++
        m.reconnectDelay *= 2 // Exponential backoff
        log.Printf("Reconnection attempt %d failed: %v", m.reconnects, err)
    }

    return fmt.Errorf("failed to reconnect after %d attempts", m.maxReconnects)
}
```

### State Recovery

```go
func (s *Session) RecoverState() error {
    // Replay conversation context
    if err := s.llm.SetContext(s.conversationHistory); err != nil {
        return err
    }

    // Restore variables
    for k, v := range s.savedVariables {
        s.templateEngine.SetDynamicVar(k, v)
    }

    // Inform user of recovery
    s.tts.Speak("I apologize for the interruption. Where were we?")

    return nil
}
```

## Logging and Monitoring

### Error Metrics

```go
var (
    errorCounter = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "voice_agent_errors_total",
            Help: "Total number of errors by type and provider",
        },
        []string{"type", "provider", "recoverable"},
    )

    errorLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "voice_agent_error_recovery_seconds",
            Help:    "Time to recover from errors",
            Buckets: []float64{0.1, 0.5, 1, 2, 5, 10},
        },
        []string{"type"},
    )
)

func RecordError(err error) {
    var provErr *ProviderError
    if errors.As(err, &provErr) {
        errorCounter.WithLabelValues(
            provErr.Code,
            provErr.Provider,
            strconv.FormatBool(provErr.Retryable),
        ).Inc()
    }
}
```

### Structured Logging

```go
func LogError(ctx context.Context, err error, fields map[string]any) {
    entry := log.WithFields(log.Fields{
        "call_id":    GetCallID(ctx),
        "session_id": GetSessionID(ctx),
        "error":      err.Error(),
    })

    for k, v := range fields {
        entry = entry.WithField(k, v)
    }

    var provErr *ProviderError
    if errors.As(err, &provErr) {
        entry = entry.WithFields(log.Fields{
            "provider":  provErr.Provider,
            "operation": provErr.Operation,
            "code":      provErr.Code,
            "retryable": provErr.Retryable,
        })
    }

    entry.Error("Error occurred")
}
```

## Best Practices

### 1. Never Let Errors Propagate Silently

```go
// Bad
result, _ := stt.Transcribe(ctx, audio)

// Good
result, err := stt.Transcribe(ctx, audio)
if err != nil {
    log.Printf("STT error: %v", err)
    p.handleSTTError(err)
}
```

### 2. Use Context for Timeouts

```go
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

result, err := provider.Call(ctx, params)
if errors.Is(err, context.DeadlineExceeded) {
    // Handle timeout specifically
}
```

### 3. Test Error Paths

```go
func TestSTTFailover(t *testing.T) {
    // Create failing primary provider
    primary := &MockSTT{ShouldFail: true}
    fallback := &MockSTT{ShouldFail: false}

    chain := NewFallbackChain(primary, fallback)

    result, err := chain.Transcribe(ctx, audio)
    assert.NoError(t, err)
    assert.True(t, fallback.Called)
}
```

## Next Steps

- [Monitoring](https://edesy.in/docs/voice-agent/advanced/monitoring) - Observability setup
- [Security](https://edesy.in/docs/voice-agent/advanced/security) - Security best practices
- [Scaling](https://edesy.in/docs/voice-agent/advanced/scaling) - High availability
