Error Handling

Build resilient messaging integrations with proper error handling, retries, and circuit breakers.

Overview

When sending messages, errors can occur at multiple levels:

  • API errors - Invalid requests, authentication failures
  • Network errors - Connection timeouts, DNS failures
  • Provider errors - Carrier issues, rate limiting
  • Business errors - Insufficient balance, template not approved

This guide covers patterns for handling each type of error.

Error Types

API Errors (4xx)

Client-side errors indicating a problem with the request:

StatusError CodeMeaningAction
400VALIDATION_001Invalid request formatCheck request body
401AUTH_001Invalid API keyVerify API key
404RESOURCE_001Resource not foundVerify IDs
409CONFLICT_001Resource conflict (e.g. duplicate idempotency key)Retry with new idempotency key
422VALIDATION_002Validation failedCheck field requirements
429BUSINESS_002Rate limit exceededImplement backoff (200 req/min std, 10 req/min sensitive)

Server Errors (5xx)

Temporary server-side errors - safe to retry:

StatusMeaningAction
500Internal server errorRetry with backoff
502Bad gatewayRetry with backoff
503Service unavailableRetry with backoff
504Gateway timeoutRetry with backoff

Business Logic Errors

Application-level errors requiring business decisions:

Error CodeMeaningAction
BUSINESS_003Insufficient account balanceAlert billing, queue for later
BUSINESS_005Template not approvedWait or use SMS fallback
VALIDATION_002Invalid phone number formatValidate input, notify user
BUSINESS_007Recipient opted outRemove from list

Basic Error Handling

Try-Catch Pattern

import SentDm from '@sentdm/sentdm';

const client = new SentDm();

async function sendMessage(phoneNumber: string, templateId: string) {
  try {
    const response = await client.messages.send({
      to: [phoneNumber],
      template: { id: templateId }
    });
    return { success: true, messageId: response.data.recipients[0].message_id };
  } catch (error) {
    if (error instanceof SentDm.APIError) {
      return handleApiError(error);
    }
    // Network or other errors
    return { success: false, error: 'Network error', retryable: true };
  }
}

function handleApiError(error: SentDm.APIError) {
  switch (error.status) {
    case 429:
      return { success: false, error: 'Rate limited', retryable: true, delay: 60000 };
    case 401:
      return { success: false, error: 'Invalid API key', retryable: false };
    case 400:
      return { success: false, error: error.message, retryable: false };
    default:
      return { success: false, error: error.message, retryable: error.status >= 500 };
  }
}
import sent_dm
from sent_dm import SentDm

client = SentDm()

def send_message(phone_number: str, template_id: str):
    try:
        response = client.messages.send(
            to=[phone_number],
            template={"id": template_id}
        )
        return {"success": True, "message_id": response.data.recipients[0].message_id}
    except sent_dm.RateLimitError as e:
        return {"success": False, "error": "Rate limited", "retryable": True, "delay": 60}
    except sent_dm.AuthenticationError as e:
        return {"success": False, "error": "Invalid API key", "retryable": False}
    except sent_dm.BadRequestError as e:
        return {"success": False, "error": str(e), "retryable": False}
    except sent_dm.APIStatusError as e:
        return {"success": False, "error": str(e), "retryable": e.status_code >= 500}
    except sent_dm.APIConnectionError as e:
        return {"success": False, "error": "Network error", "retryable": True}
func sendMessage(phoneNumber, templateId string) (*SendResult, error) {
    client := sentdm.NewClient()

    response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
        To: []string{phoneNumber},
        Template: sentdm.MessageSendParamsTemplate{
            ID: sentdm.String(templateId),
        },
    })

    if err != nil {
        if apiErr, ok := err.(*sentdm.APIError); ok {
            return nil, handleAPIError(apiErr)
        }
        // Network or other errors
        return &SendResult{Retryable: true, Error: err}, nil
    }

    return &SendResult{Success: true, MessageID: response.Data.Recipients[0].MessageID}, nil
}

func handleAPIError(err *sentdm.APIError) error {
    switch err.StatusCode {
    case 429:
        return fmt.Errorf("rate limited, retry after %d seconds", err.RetryAfter)
    case 401:
        return fmt.Errorf("invalid API key")
    case 400:
        return fmt.Errorf("bad request: %s", err.Message)
    default:
        if err.StatusCode >= 500 {
            return fmt.Errorf("server error: %s (retryable)", err.Message)
        }
        return fmt.Errorf("API error: %s", err.Message)
    }
}
public SendResult sendMessage(String phoneNumber, String templateId) {
    try {
        MessageSendParams params = MessageSendParams.builder()
            .addTo(phoneNumber)
            .template(MessageSendParams.Template.builder()
                .id(templateId)
                .build())
            .build();

        var response = client.messages().send(params);
        return new SendResult(true, response.data().recipients().get(0).messageId(), null);
    } catch (RateLimitException e) {
        return new SendResult(false, null, "Rate limited, retry after " + e.getRetryAfter());
    } catch (AuthenticationException e) {
        return new SendResult(false, null, "Invalid API key");
    } catch (BadRequestException e) {
        return new SendResult(false, null, e.getMessage());
    } catch (APIException e) {
        boolean retryable = e.getStatusCode() >= 500;
        return new SendResult(false, null, e.getMessage() + (retryable ? " (retryable)" : ""));
    }
}
public async Task<SendResult> SendMessageAsync(string phoneNumber, string templateId)
{
    try
    {
        var parameters = new MessageSendParams
        {
            To = new List<string> { phoneNumber },
            Template = new MessageSendParamsTemplate { Id = templateId }
        };

        var response = await client.Messages.SendAsync(parameters);
        return new SendResult(true, response.Data.Recipients[0].MessageId, null);
    }
    catch (RateLimitException ex)
    {
        return new SendResult(false, null, $"Rate limited, retry after {ex.RetryAfter}");
    }
    catch (AuthenticationException ex)
    {
        return new SendResult(false, null, "Invalid API key");
    }
    catch (BadRequestException ex)
    {
        return new SendResult(false, null, ex.Message);
    }
    catch (APIException ex) when (ex.StatusCode >= 500)
    {
        return new SendResult(false, null, $"{ex.Message} (retryable)");
    }
    catch (APIException ex)
    {
        return new SendResult(false, null, ex.Message);
    }
}
function sendMessage($phoneNumber, $templateId) {
    try {
        $result = $this->client->messages->send(
            to: [$phoneNumber],
            template: ['id' => $templateId]
        );
        return ['success' => true, 'message_id' => $result->data->recipients[0]->message_id];
    } catch (RateLimitException $e) {
        return ['success' => false, 'error' => 'Rate limited', 'retryable' => true];
    } catch (AuthenticationException $e) {
        return ['success' => false, 'error' => 'Invalid API key', 'retryable' => false];
    } catch (BadRequestException $e) {
        return ['success' => false, 'error' => $e->getMessage(), 'retryable' => false];
    } catch (APIException $e) {
        $retryable = $e->getCode() >= 500;
        return ['success' => false, 'error' => $e->getMessage(), 'retryable' => $retryable];
    }
}
def send_message(phone_number, template_id)
  result = sent_dm.messages.send(
    to: [phone_number],
    template: { id: template_id }
  )
  { success: true, message_id: result.data.recipients[0].message_id }
rescue Sentdm::RateLimitError => e
  { success: false, error: 'Rate limited', retryable: true }
rescue Sentdm::AuthenticationError => e
  { success: false, error: 'Invalid API key', retryable: false }
rescue Sentdm::BadRequestError => e
  { success: false, error: e.message, retryable: false }
rescue Sentdm::APIError => e
  retryable = e.code >= 500
  { success: false, error: e.message, retryable: retryable }
end

Retry Strategies

Exponential Backoff

Increase wait time between retries to avoid overwhelming the API:

async function sendWithRetry(
  phoneNumber: string,
  templateId: string,
  maxRetries: number = 3
): Promise<SendResult> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const result = await sendMessage(phoneNumber, templateId);

    if (result.success || !result.retryable) {
      return result;
    }

    if (attempt < maxRetries) {
      // Exponential backoff: 1s, 2s, 4s
      const delay = Math.pow(2, attempt) * 1000;
      console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
      await sleep(delay);
    }
  }

  return { success: false, error: 'Max retries exceeded' };
}

Jitter

Add randomness to prevent thundering herd:

function sleepWithJitter(baseDelay: number): Promise<void> {
  const jitter = Math.random() * 1000; // 0-1000ms random
  return sleep(baseDelay + jitter);
}

// Usage
const delay = Math.pow(2, attempt) * 1000;
await sleepWithJitter(delay);

Circuit Breaker Pattern

Stop trying when failures are consistent:

class CircuitBreaker {
  private failures = 0;
  private lastFailureTime?: number;
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';

  constructor(
    private threshold = 5,
    private timeout = 60000
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - (this.lastFailureTime || 0) > this.timeout) {
        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.lastFailureTime = Date.now();

    if (this.failures >= this.threshold) {
      this.state = 'OPEN';
    }
  }
}

// Usage
const breaker = new CircuitBreaker(5, 60000);

async function sendWithCircuitBreaker(phoneNumber: string, templateId: string) {
  return breaker.execute(() => sendMessage(phoneNumber, templateId));
}

Idempotency

Prevent duplicate messages when retrying:

async function sendMessageIdempotent(
  phoneNumber: string,
  templateId: string,
  idempotencyKey: string
) {
  try {
    return await client.messages.send({
      to: [phoneNumber],
      template: { id: templateId }
    }, {
      headers: { 'Idempotency-Key': idempotencyKey }
    });
  } catch (error) {
    if (error.code === 'CONFLICT_001') {
      // Duplicate request - message already sent with this key
      console.log('Message already sent with this idempotency key');
      return { success: true, duplicate: true };
    }
    throw error;
  }
}

// Generate idempotency key from business context
const idempotencyKey = `order_confirmation_${orderId}_${userId}`;
await sendMessageIdempotent(phoneNumber, templateId, idempotencyKey);

Fallback Strategies

Channel Fallback

When WhatsApp fails, fall back to SMS:

async function sendWithFallback(phoneNumber: string, templateId: string) {
  // Try WhatsApp first
  const whatsappResult = await sendMessage(phoneNumber, templateId, ['whatsapp']);

  if (whatsappResult.success) {
    return whatsappResult;
  }

  // Check if error is retryable on another channel
  if (whatsappResult.error === 'whatsapp_not_available') {
    console.log('WhatsApp unavailable, falling back to SMS');
    return await sendMessage(phoneNumber, templateId, ['sms']);
  }

  return whatsappResult;
}

Queue for Later

When account has insufficient balance:

async function sendOrQueue(phoneNumber: string, templateId: string) {
  const result = await sendMessage(phoneNumber, templateId);

  if (!result.success && result.code === 'BUSINESS_003') {
    // Queue for later processing
    await messageQueue.add({
      phoneNumber,
      templateId,
      scheduledFor: new Date(Date.now() + 3600000) // Retry in 1 hour
    });

    // Alert billing team
    await alertBillingTeam('Low balance - messages queued');

    return { success: false, queued: true };
  }

  return result;
}

Monitoring and Alerting

Error Metrics

Track error rates to detect issues:

// Increment counters
errorCounter.labels({ type: 'rate_limited' }).inc();
errorCounter.labels({ type: 'network' }).inc();

// Alert on high error rates
if (errorRate > 0.1) { // 10% error rate
  await sendAlert('High message send error rate', { errorRate });
}

Structured Logging

Log errors with context for debugging:

logger.error('Message send failed', {
  error: error.message,
  errorCode: error.code,
  phoneNumber: maskPhone(phoneNumber),
  templateId,
  attempt: attemptNumber,
  retryable: isRetryable(error)
});

Best Practices

1. Distinguish Retryable vs Non-Retryable

function isRetryable(error: APIError): boolean {
  // Never retry auth errors
  if (error.status === 401) {
    return false;
  }

  // Never retry payment / balance errors
  if (error.status === 402) {
    return false;
  }

  // Don't retry validation errors
  if (error.status === 400 || error.status === 422) {
    return false;
  }

  // Retry server errors and rate limits
  return error.status >= 500 || error.status === 429;
}

2. Set Maximum Retry Limits

Prevent infinite loops:

const MAX_RETRIES = 3;
const MAX_DELAY = 30000; // 30 seconds

const delay = Math.min(Math.pow(2, attempt) * 1000, MAX_DELAY);

3. Fail Fast for User-Facing Errors

Don't retry if user needs to fix something:

if (error.code === 'VALIDATION_002') {
  // Show error to user immediately
  return { success: false, userError: 'Please enter a valid phone number' };
}

4. Use Dead Letter Queues

For messages that ultimately fail:

async function processMessage(message: Message) {
  const result = await sendWithRetry(message);

  if (!result.success) {
    // Move to dead letter queue for manual review
    await deadLetterQueue.add({
      originalMessage: message,
      error: result.error,
      attempts: result.attempts,
      failedAt: new Date()
    });
  }
}

Testing Error Handling

Simulate Errors in Sandbox Mode

// Test rate limiting
const result = await sendMessage('test-rate-limit', templateId);
expect(result.retryable).toBe(true);

// Test invalid template
const result2 = await sendMessage(phoneNumber, 'invalid-template-id');
expect(result2.error).toContain('template');

Chaos Engineering

Randomly inject failures to test resilience:

async function sendWithChaos(phoneNumber: string, templateId: string) {
  if (process.env.CHAOS_MODE && Math.random() < 0.1) {
    throw new Error('Simulated network error');
  }
  return sendMessage(phoneNumber, templateId);
}

On this page