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:
| Status | Error Code | Meaning | Action |
|---|---|---|---|
| 400 | VALIDATION_001 | Invalid request format | Check request body |
| 401 | AUTH_001 | Invalid API key | Verify API key |
| 404 | RESOURCE_001 | Resource not found | Verify IDs |
| 409 | CONFLICT_001 | Resource conflict (e.g. duplicate idempotency key) | Retry with new idempotency key |
| 422 | VALIDATION_002 | Validation failed | Check field requirements |
| 429 | BUSINESS_002 | Rate limit exceeded | Implement backoff (200 req/min std, 10 req/min sensitive) |
Server Errors (5xx)
Temporary server-side errors - safe to retry:
| Status | Meaning | Action |
|---|---|---|
| 500 | Internal server error | Retry with backoff |
| 502 | Bad gateway | Retry with backoff |
| 503 | Service unavailable | Retry with backoff |
| 504 | Gateway timeout | Retry with backoff |
Business Logic Errors
Application-level errors requiring business decisions:
| Error Code | Meaning | Action |
|---|---|---|
BUSINESS_003 | Insufficient account balance | Alert billing, queue for later |
BUSINESS_005 | Template not approved | Wait or use SMS fallback |
VALIDATION_002 | Invalid phone number format | Validate input, notify user |
BUSINESS_007 | Recipient opted out | Remove 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 }
endRetry 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);
}