Skip to main content
Production integrations must handle errors gracefully to provide a reliable experience. Network issues, rate limits, and temporary server problems are inevitable - your integration should recover from them automatically when possible and fail gracefully when not.

Overview

Why Error Handling Matters

Robust error handling ensures your integration:
  • Stays reliable - Automatically recovers from transient failures
  • Protects data - Prevents data loss during network interruptions
  • Provides visibility - Gives clear feedback when issues occur
  • Scales gracefully - Handles rate limits without cascading failures
The Sully.ai SDKs include built-in retry logic for transient errors. This guide covers the underlying concepts and how to customize behavior for production use cases.

Error Response Format

All Sully.ai API errors follow a consistent structure:
{
  "error": {
    "message": "The requested resource was not found",
    "code": "resource_not_found",
    "type": "invalid_request_error"
  }
}
FieldDescription
messageHuman-readable error description
codeMachine-readable error code for programmatic handling
typeError category (e.g., invalid_request_error, authentication_error, rate_limit_error)
The HTTP response also includes the appropriate status code in the response header.

HTTP Status Codes

CodeMeaningAction
400Bad Request - Invalid input parametersFix the request, do not retry
401Unauthorized - Invalid or missing API keyCheck credentials, do not retry
403Forbidden - Not allowed to access resourceCheck permissions or account status
404Not Found - Resource does not existVerify the resource ID
429Too Many Requests - Rate limitedRetry with exponential backoff
500Internal Server Error - Server-side issueRetry with exponential backoff
502Bad Gateway - Upstream service issueRetry with exponential backoff
503Service Unavailable - Temporary overloadRetry with exponential backoff
504Gateway Timeout - Request took too longRetry with exponential backoff

Transient vs Permanent Errors

Understanding which errors are transient (temporary) versus permanent is critical for implementing proper retry logic.

Transient Errors (Retry)

These errors are temporary and may succeed if retried:
  • 429 - Rate limited, will succeed after backoff
  • 500 - Server error, often recovers quickly
  • 502/503/504 - Gateway or availability issues
  • Network errors - Connection timeouts, DNS failures

Permanent Errors (Do Not Retry)

These errors indicate a problem with the request itself:
  • 400 - Bad request parameters
  • 401 - Invalid credentials
  • 403 - Permission denied
  • 404 - Resource not found
function isTransientError(statusCode: number): boolean {
  const transientCodes = [429, 500, 502, 503, 504];
  return transientCodes.includes(statusCode);
}

function isNetworkError(error: unknown): boolean {
  if (error instanceof Error) {
    const networkErrors = [
      'ECONNRESET',
      'ECONNREFUSED',
      'ETIMEDOUT',
      'ENOTFOUND',
      'EAI_AGAIN',
    ];
    return networkErrors.some((code) => error.message.includes(code));
  }
  return false;
}

function shouldRetry(error: unknown): boolean {
  // Network errors are always transient
  if (isNetworkError(error)) {
    return true;
  }

  // Check HTTP status code
  if (error instanceof APIError) {
    return isTransientError(error.status);
  }

  return false;
}

Retry Strategies

Exponential Backoff with Jitter

The recommended retry strategy uses exponential backoff with random jitter to prevent thundering herd problems:
delay = min(maxDelay, baseDelay * 2^attempt) + random(0, jitter)
  • Base delay: Starting wait time (e.g., 1000ms)
  • Max delay: Maximum wait time cap (e.g., 30000ms)
  • Jitter: Random variation to spread out retries (e.g., 0-25% of delay)

Complete Retry Wrapper

interface RetryConfig {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterFraction: number;
}

const defaultRetryConfig: RetryConfig = {
  maxAttempts: 3,
  baseDelayMs: 1000,
  maxDelayMs: 30000,
  jitterFraction: 0.25,
};

function calculateDelay(attempt: number, config: RetryConfig): number {
  const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt);
  const cappedDelay = Math.min(exponentialDelay, config.maxDelayMs);
  const jitter = cappedDelay * Math.random() * config.jitterFraction;
  return Math.floor(cappedDelay + jitter);
}

async function withRetry<T>(
  operation: () => Promise<T>,
  config: Partial<RetryConfig> = {}
): Promise<T> {
  const finalConfig = { ...defaultRetryConfig, ...config };
  let lastError: Error | undefined;

  for (let attempt = 0; attempt < finalConfig.maxAttempts; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      // Don't retry permanent errors
      if (!shouldRetry(error)) {
        throw error;
      }

      // Don't wait after the last attempt
      if (attempt < finalConfig.maxAttempts - 1) {
        const delay = calculateDelay(attempt, finalConfig);
        console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError ?? new Error('All retry attempts failed');
}

// Usage
const note = await withRetry(
  () => client.notes.retrieve(noteId),
  { maxAttempts: 5 }
);

When to Give Up

Set reasonable limits to avoid infinite retry loops:
  • Max attempts: 3-5 attempts for most operations
  • Max total time: Cap total retry time (e.g., 2 minutes)
  • Circuit breaker: After repeated failures, stop retrying temporarily
Never retry indefinitely. Set a maximum number of attempts and handle the final failure gracefully - log the error, notify the user, or queue for manual review.

SDK Error Handling

The Sully.ai SDKs provide specific error classes for different failure scenarios, making it easy to handle errors appropriately.

TypeScript SDK

import SullyAI, {
  APIError,
  AuthenticationError,
  RateLimitError,
  BadRequestError,
  NotFoundError,
} from '@sullyai/sullyai';

const client = new SullyAI();

async function createNoteWithErrorHandling(transcript: string): Promise<void> {
  try {
    const note = await client.notes.create({
      transcript,
      noteType: { type: 'soap' },
    });
    console.log('Note created:', note.noteId);
  } catch (error) {
    if (error instanceof AuthenticationError) {
      // Invalid API key or account ID - check configuration
      console.error('Authentication failed. Check your API credentials.');
      // Don't retry - fix credentials first
    } else if (error instanceof RateLimitError) {
      // Too many requests - SDK handles retries automatically
      // For manual handling:
      console.error(`Rate limited. Retry after: ${error.retryAfter}s`);
    } else if (error instanceof BadRequestError) {
      // Invalid request - log and fix the request
      console.error('Invalid request:', error.message);
      // Don't retry - fix the request parameters
    } else if (error instanceof NotFoundError) {
      // Resource doesn't exist
      console.error('Resource not found:', error.message);
    } else if (error instanceof APIError) {
      // Other API errors - check if transient
      console.error(`API error (${error.status}):`, error.message);
      console.error('Request ID:', error.requestId); // Useful for support
    } else {
      // Unknown error
      throw error;
    }
  }
}

Python SDK

from sullyai import SullyAI
from sullyai.errors import (
    APIError,
    AuthenticationError,
    RateLimitError,
    BadRequestError,
    NotFoundError,
    APIConnectionError,
)

client = SullyAI()


def create_note_with_error_handling(transcript: str) -> None:
    try:
        note = client.notes.create(
            transcript=transcript,
            note_type={"type": "soap"},
        )
        print(f"Note created: {note.note_id}")
    except AuthenticationError:
        # Invalid API key or account ID - check configuration
        print("Authentication failed. Check your API credentials.")
        # Don't retry - fix credentials first
    except RateLimitError as e:
        # Too many requests - SDK handles retries automatically
        # For manual handling:
        print(f"Rate limited. Retry after: {e.retry_after}s")
    except BadRequestError as e:
        # Invalid request - log and fix the request
        print(f"Invalid request: {e.message}")
        # Don't retry - fix the request parameters
    except NotFoundError as e:
        # Resource doesn't exist
        print(f"Resource not found: {e.message}")
    except APIConnectionError:
        # Network issue - may be transient
        print("Failed to connect to API. Check network connection.")
    except APIError as e:
        # Other API errors
        print(f"API error ({e.status_code}): {e.message}")

Error Properties

SDK errors include helpful properties for debugging and logging:
PropertyDescription
status / status_codeHTTP status code
messageHuman-readable error description
requestId / request_idUnique request ID for support inquiries
retryAfter / retry_afterSeconds to wait before retrying (rate limits)

WebSocket Error Recovery

Real-time streaming connections require special error handling for connection drops, token expiration, and state recovery.

Connection Drops

WebSocket connections can drop unexpectedly due to network issues. Implement automatic reconnection:
class StreamConnection {
  private reconnectAttempt = 0;
  private readonly maxReconnectAttempts = 5;

  private async handleDisconnect(event: CloseEvent): Promise<void> {
    // Normal closure - don't reconnect
    if (event.code === 1000) {
      return;
    }

    // Unexpected disconnect - attempt reconnection
    if (this.reconnectAttempt < this.maxReconnectAttempts) {
      const delay = this.calculateBackoff();
      console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt + 1})`);

      await this.sleep(delay);
      this.reconnectAttempt++;
      await this.connect();
    } else {
      console.error('Max reconnection attempts reached');
      this.emit('error', new Error('Connection failed after max retries'));
    }
  }
}

Token Expiration

Streaming tokens have limited validity. Handle expiration gracefully:
ws.onclose = async (event) => {
  // 401 close code indicates token expiration
  if (event.code === 4001 || event.reason.includes('token')) {
    console.log('Token expired, fetching new token...');

    // Get fresh token before reconnecting
    const newToken = await fetchStreamingToken();
    await this.connectWithToken(newToken);
  } else {
    await this.handleDisconnect(event);
  }
};

State Recovery After Reconnection

Buffer audio during reconnection to prevent data loss:
private audioBuffer: string[] = [];

sendAudio(audioData: ArrayBuffer): void {
  const base64Audio = this.toBase64(audioData);

  if (this.isConnected()) {
    this.ws.send(JSON.stringify({ audio: base64Audio }));
  } else if (this.isReconnecting()) {
    // Buffer audio during reconnection
    this.audioBuffer.push(base64Audio);
  }
}

private async onReconnected(): Promise<void> {
  // Flush buffered audio after reconnection
  for (const audio of this.audioBuffer) {
    this.ws.send(JSON.stringify({ audio }));
  }
  this.audioBuffer = [];
}
For complete WebSocket error handling implementation, see the Audio Transcription guide.

Timeout Handling

Setting Appropriate Timeouts

Configure timeouts based on operation type:
OperationRecommended Timeout
Simple API calls30 seconds
File uploads (small)60 seconds
File uploads (large)120+ seconds
WebSocket connection10 seconds
import SullyAI from '@sullyai/sullyai';
import * as fs from 'fs';

// Default client timeout
const client = new SullyAI({
  timeout: 60000, // 60 seconds
});

// Per-request timeout for large files
const transcription = await client.audio.transcriptions.create(
  { audio: fs.createReadStream('large-recording.mp3') },
  { timeout: 180000 } // 3 minutes for large files
);

Handling Timeout Errors

Timeout does not mean failure - the operation may still complete on the server:
import SullyAI, { APIError } from '@sullyai/sullyai';

async function uploadWithTimeoutHandling(
  filePath: string
): Promise<string> {
  const client = new SullyAI({ timeout: 60000 });

  try {
    const transcription = await client.audio.transcriptions.create({
      audio: fs.createReadStream(filePath),
    });
    return transcription.transcriptionId;
  } catch (error) {
    if (error instanceof Error && error.message.includes('timeout')) {
      // Timeout occurred - the upload may have succeeded
      console.warn('Request timed out. The upload may still be processing.');
      console.warn('Check your dashboard or implement idempotency keys.');

      // Option 1: Return early and handle async
      // Option 2: Retry with longer timeout
      // Option 3: Use webhooks to get notified when complete
      throw new Error('Upload timed out - check status manually');
    }
    throw error;
  }
}
A timeout error does not mean the operation failed. The server may have received and processed the request. Use webhooks or implement idempotency to handle this safely.

Next Steps