Ferni Developers
Open Console
Developer Blog

Error Handling Patterns for Voice AI

Error Handling Patterns for Voice AI

Voice errors are different. Users can't see stack traces. Silence is the worst error message. Here's how to handle failures gracefully.

The Golden Rule

Never leave users in silence. Any error should produce some audio feedback.

// ❌ Bad: Silent failure
try {
  const result = await fetchData();
} catch (error) {
  console.error(error);
  // User hears nothing...
}

// ✅ Good: Always respond
try {
  const result = await fetchData();
} catch (error) {
  console.error(error);
  return {
    response: "I couldn't get that information right now. " +
              "Want me to try again?",
    retry: true,
  };
}

Pattern 1: Retry with Backoff

async function withRetry<T>(
  fn: () => Promise<T>,
  options: {
    maxAttempts?: number;
    backoff?: 'linear' | 'exponential';
    onRetry?: (attempt: number, error: Error) => void;
  } = {}
): Promise<T> {
  const { maxAttempts = 3, backoff = 'exponential', onRetry } = options;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) throw error;

      const delay = backoff === 'exponential'
        ? Math.pow(2, attempt) * 100
        : attempt * 200;

      onRetry?.(attempt, error as Error);
      await sleep(delay);
    }
  }

  throw new Error('Unreachable');
}

// Usage
const data = await withRetry(
  () => client.fetchUserData(userId),
  {
    maxAttempts: 3,
    onRetry: (attempt) => {
      if (attempt > 1) {
        speak("Just a moment, reconnecting...");
      }
    },
  }
);

Pattern 2: Graceful Degradation

When a feature fails, fall back to simpler alternatives:

async function getWeather(location: string) {
  // Try primary source
  try {
    return await weatherApi.getForecast(location);
  } catch (primaryError) {
    console.warn('Primary weather API failed:', primaryError);
  }

  // Fall back to cached data
  const cached = await cache.get(`weather:${location}`);
  if (cached && isRecent(cached, { hours: 2 })) {
    return {
      ...cached,
      note: "Using recent data - live weather temporarily unavailable",
    };
  }

  // Fall back to general response
  return {
    type: 'unavailable',
    response: "I can't check the weather right now. " +
              "You might want to look outside or check your phone.",
  };
}

Pattern 3: Circuit Breaker

Prevent cascading failures by stopping requests to failing services:

class CircuitBreaker {
  private failures = 0;
  private lastFailure: number = 0;
  private state: 'closed' | 'open' | 'half-open' = 'closed';

  constructor(
    private threshold = 5,
    private resetTimeout = 30000
  ) {}

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  private onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.threshold) {
      this.state = 'open';
    }
  }
}

// Usage
const weatherBreaker = new CircuitBreaker(5, 30000);

async function getWeatherSafe(location: string) {
  try {
    return await weatherBreaker.call(() => weatherApi.get(location));
  } catch (error) {
    if (error.message === 'Circuit breaker is open') {
      return "Weather service is temporarily unavailable. Try again in a minute.";
    }
    throw error;
  }
}

Pattern 4: User-Friendly Error Messages

Map technical errors to human responses:

const errorMessages: Record<string, string> = {
  'NETWORK_ERROR': "I'm having trouble connecting. Is your internet working?",
  'RATE_LIMITED': "I need to slow down for a moment. Can you repeat that in a few seconds?",
  'CONTEXT_LIMIT': "That's a lot to process! Let me summarize what we've discussed and continue.",
  'AUTH_EXPIRED': "I need you to sign in again. I'll wait.",
  'SERVICE_UNAVAILABLE': "That feature isn't working right now. Is there something else I can help with?",
};

function handleError(error: FerniError): VoiceResponse {
  const message = errorMessages[error.code] ||
    "Something went wrong. Let me try that again.";

  return {
    response: message,
    action: error.code === 'AUTH_EXPIRED' ? 'prompt_login' : 'retry',
    logError: true,
  };
}

Pattern 5: Timeout Handling

Voice has strict latency requirements:

async function withTimeout<T>(
  promise: Promise<T>,
  ms: number,
  fallback?: () => T
): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), ms)
  );

  try {
    return await Promise.race([promise, timeout]);
  } catch (error) {
    if (error.message === 'Timeout' && fallback) {
      return fallback();
    }
    throw error;
  }
}

// Usage: 200ms timeout with fallback
const response = await withTimeout(
  complexProcessing(input),
  200,
  () => ({
    response: "Let me think about that for a moment...",
    continue: true,
  })
);

Pattern 6: Error Boundaries

Wrap conversation sections in error boundaries:

class ConversationErrorBoundary {
  async execute(
    fn: () => Promise<VoiceResponse>,
    context: ConversationContext
  ): Promise<VoiceResponse> {
    try {
      return await fn();
    } catch (error) {
      // Log for debugging
      console.error('Conversation error:', error);

      // Track for monitoring
      metrics.increment('conversation_errors', {
        type: error.code || 'unknown',
        stage: context.currentStage,
      });

      // Attempt recovery
      if (this.canRecover(error)) {
        return this.recover(error, context);
      }

      // Graceful failure
      return {
        response: "I ran into an issue. Let's start fresh - how can I help you?",
        action: 'reset_context',
      };
    }
  }

  private canRecover(error: Error): boolean {
    return ['CONTEXT_LIMIT', 'TRANSIENT_FAILURE'].includes(error.code);
  }

  private async recover(error: Error, context: ConversationContext) {
    if (error.code === 'CONTEXT_LIMIT') {
      await context.compact();
      return { response: "Got it. Please continue.", retry: true };
    }
    // Other recovery strategies...
  }
}

Quick Reference

Error Type User Message Action
Network "Connection issue..." Auto-retry
Rate limit "Need to slow down..." Backoff
Auth "Please sign in..." Prompt login
Timeout "Let me think..." Continue async
Unknown "Let's try that again..." Retry once

Next Steps