Build a Node.js Express bulk SMS broadcaster with Sinch
This guide provides a complete walkthrough for building a production-ready bulk SMS messaging application using Node.js, Express, and the Sinch SMS API. We'll cover everything from initial project setup to deployment and monitoring, enabling you to reliably send messages to large groups of recipients.
We aim to create a robust service capable of accepting a list of phone numbers and a message, then efficiently dispatching those messages via Sinch's powerful SMS infrastructure. This solves the common need for applications to send notifications, alerts, or marketing messages at scale.
Technologies Used:
- Node.js: A JavaScript runtime for building server-side applications.
- Express: A minimal and flexible Node.js web application framework.
- Sinch SMS API: A third-party service for sending and receiving SMS messages globally.
- dotenv: A module to load environment variables from a
.env
file. - node-fetch (v2): A module to make HTTP requests (we'll use v2 for CommonJS compatibility).
- (Optional) Prisma: A modern ORM for database access (for managing recipient lists).
- (Optional) PostgreSQL/SQLite: A relational database.
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account with API credentials (Service Plan ID, API Token) and a provisioned virtual number.
- Basic understanding of Node.js, Express, and REST APIs.
- (Optional) Docker installed for containerization.
- (Optional) A PostgreSQL database instance if using Prisma for recipient management.
System Architecture
Here's a high-level overview of the system we'll build:
+-------------+ +-------------------+ +-----------------+ +--------------+
| User/Client | ----> | Node.js/Express | ----> | Sinch Service | ----> | Sinch SMS API| ----> SMS
| (e.g. curl) | | API Layer | | (Our Wrapper) | | |
+-------------+ +-------------------+ +-----------------+ +--------------+
| |
| Optional | Optional
| |
+-----------------------+--------------------> +-----------------+
| | Database (e.g., |
| | PostgreSQL) |
+---------------------> +-----------------+
(Recipient Lists)
By the end of this guide, you'll have a deployable Node.js application with a secure API endpoint for initiating bulk SMS broadcasts via Sinch.
1. Setting up the project
Let's start by creating our project directory and initializing a Node.js project.
-
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-bulk-sms-broadcaster cd sinch-bulk-sms-broadcaster
-
Initialize Node.js Project: Initialize the project using npm. The
-y
flag accepts default settings.npm init -y
-
Install Dependencies: We need Express for the web server,
dotenv
to manage environment variables, andnode-fetch
(specifically version 2 for easy CommonJSrequire
usage) to make requests to the Sinch API.npm install express dotenv node-fetch@2
- Why
node-fetch@2
? Version 3+ ofnode-fetch
uses ES Modules, which requires slightly different setup ("type": "module"
inpackage.json
andimport
syntax). Using v2 simplifies compatibility with the standard CommonJSrequire
syntax often found in Express projects.
- Why
-
Install Development Dependencies (Optional but Recommended): Install
nodemon
to automatically restart the server during development.npm install --save-dev nodemon
-
Create Project Structure: Organize the project for clarity and maintainability.
mkdir src mkdir src/routes mkdir src/controllers mkdir src/services mkdir src/middleware mkdir src/config mkdir src/utils touch src/app.js touch src/server.js touch .env touch .gitignore
src/
: Contains all source code.routes/
: Defines API endpoints.controllers/
: Handles incoming requests and orchestrates responses.services/
: Encapsulates business logic, like interacting with Sinch.middleware/
: Holds Express middleware functions (e.g., authentication).config/
: Stores configuration files (though we'll primarily use.env
).utils/
: Contains helper functions.app.js
: Configures the Express application instance.server.js
: Starts the HTTP server..env
: Stores sensitive credentials (API keys, etc.). Never commit this file..gitignore
: Specifies files/directories Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to prevent them from being committed to version control.# .gitignore node_modules/ .env *.log
-
Configure
.env
: Create placeholder environment variables. We'll get the actual values later.# .env # Server Configuration PORT=3000 # Sinch API Credentials SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID SINCH_API_TOKEN=YOUR_API_TOKEN SINCH_VIRTUAL_NUMBER=YOUR_SINCH_NUMBER # Your purchased or assigned Sinch number in E.164 format (e.g., +12025550181) # Application Security INTERNAL_API_KEY=generate-a-strong-secure-random-string # Used for basic internal API protection
- Why
.env
? Storing configuration like API keys separately from code is crucial for security and deployment flexibility.dotenv
loads these intoprocess.env
at runtime.
- Why
-
Add Run Scripts to
package.json
: Add scripts for starting the server easily. Suggest using latest stable or appropriate version ranges.// package.json (add or modify the "scripts" section) { "name": "sinch-bulk-sms-broadcaster", "version": "1.0.0", "description": "Bulk SMS broadcaster using Node.js, Express, and Sinch", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "sms", "bulk", "sinch", "node", "express" ], "author": "", "license": "ISC", "dependencies": { "dotenv": "^16.x", // Use appropriate latest major version range "express": "^4.x", // Use appropriate latest major version range "node-fetch": "^2.6.7" // Stick to v2 range }, "devDependencies": { "nodemon": "^2.x" // Use appropriate latest major version range } }
-
Create Basic Express App (
src/app.js
): Set up the Express application instance and load environment variables.// src/app.js const express = require('express'); const dotenv = require('dotenv'); const messagingRoutes = require('./routes/messagingRoutes'); // We'll create this next const { errorHandler } = require('./middleware/errorHandler'); // We'll create this later const { healthCheck } = require('./controllers/healthController'); // We'll create this later // Load environment variables from .env file dotenv.config(); const app = express(); // Middleware to parse JSON request bodies app.use(express.json()); // --- Routes --- // Example health check endpoint (implementation needed in healthController.js) // app.get('/health', healthCheck); app.get('/health', (req, res) => res.status(200).json({ status: 'OK' })); // Simple inline health check app.use('/api/v1/messaging', messagingRoutes); // Mount messaging routes // --- Error Handling --- // Add a simple 404 handler for routes not found app.use((req, res, next) => { res.status(404).json({ message: 'Not Found' }); }); // Centralized error handler middleware app.use(errorHandler); module.exports = app;
-
Create Server Entry Point (
src/server.js
): Import the app and start the HTTP server.// src/server.js const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); });
Now you can run npm run dev
in your terminal. The server should start, though we haven't defined all routes and handlers yet.
2. Implementing core functionality (Sinch Service)
We'll create a dedicated service to handle all interactions with the Sinch API. This keeps our API controller clean and makes the Sinch logic reusable and testable.
-
Create Sinch Service File: Create the file
src/services/sinchService.js
. -
Implement
sendBulkSms
Function: This function takes an array of recipient phone numbers and the message body, then constructs and sends the request to the Sinch Batch SMS API.// src/services/sinchService.js const fetch = require('node-fetch'); const SINCH_API_BASE_URL = 'https://us.sms.api.sinch.com/xms/v1'; // Use EU or other region if needed /** * Sends a bulk SMS message using the Sinch Batch API. * * @param {string[]} recipients - An array of phone numbers in E.164 format (e.g., ['+12025550181', '+12025550182']). * @param {string} messageBody - The text content of the SMS message. * @param {string} [clientReference=null] - Optional unique identifier for the batch. * @returns {Promise<object>} - The response object from the Sinch API. * @throws {Error} - Throws an error if the API request fails or returns an error status. */ async function sendBulkSms(recipients, messageBody, clientReference = null) { const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID; const apiToken = process.env.SINCH_API_TOKEN; const sinchNumber = process.env.SINCH_VIRTUAL_NUMBER; if (!servicePlanId || !apiToken || !sinchNumber) { console.error('Error: Sinch API credentials or virtual number not configured in .env'); throw new Error('Sinch service not configured.'); } if (!recipients || recipients.length === 0) { throw new Error('Recipient list cannot be empty.'); } if (!messageBody) { throw new Error('Message body cannot be empty.'); } // Validate recipient format (basic check for E.164) const invalidNumbers = recipients.filter(num => !/^\+[1-9]\d{1,14}$/.test(num)); if (invalidNumbers.length > 0) { throw new Error(`Invalid E.164 phone number format detected: ${invalidNumbers.join(', ')}`); } const endpoint = `${SINCH_API_BASE_URL}/${servicePlanId}/batches`; const payload = { to: recipients, from: sinchNumber, body: messageBody, // Optional: Add delivery report configuration if needed // delivery_report: 'summary', // Or 'full' // Optional: Set a client reference for tracking ...(clientReference && { client_reference: clientReference }) // Optional: Add feedback_enabled if using delivery feedback feature // feedback_enabled: true }; console.log(`Sending bulk SMS to ${recipients.length} recipients via Sinch...`); try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiToken}` }, body: JSON.stringify(payload) }); const responseBody = await response.json(); if (!response.ok) { // Log detailed error from Sinch console.error(`Sinch API Error (${response.status}):`, responseBody); throw new Error(`Sinch API request failed with status ${response.status}: ${responseBody.error?.message || response.statusText}`); } console.log('Sinch API Response:', responseBody); // The responseBody contains the batch_id, which can be used for tracking return responseBody; } catch (error) { console.error('Error sending request to Sinch:', error); // Re-throw a more specific error or handle appropriately if (error.message.startsWith('Sinch API request failed')) { throw error; // Keep the specific Sinch error } throw new Error('Failed to communicate with Sinch API.'); } } module.exports = { sendBulkSms };
- Why this structure?
- It fetches credentials securely from
process.env
. - It performs basic input validation (non-empty recipients/message, E.164 format).
- It constructs the exact payload required by the Sinch
batches
endpoint. - It uses
node-fetch
to make the POST request with the correct headers (Content-Type
,Authorization
). - It checks the response status (
response.ok
) and parses the JSON body. - It throws informative errors for failed requests or configuration issues.
- It logs relevant information for debugging.
- It fetches credentials securely from
- Why this structure?
3. Building a complete API layer
Now, let's expose the bulk sending functionality through a secure Express API endpoint.
-
Create Authentication Middleware (
src/middleware/authMiddleware.js
): Implement simple API key authentication to protect the endpoint.// src/middleware/authMiddleware.js function authenticateApiKey(req, res, next) { const apiKey = req.headers['x-api-key']; const expectedApiKey = process.env.INTERNAL_API_KEY; if (!expectedApiKey) { console.error('INTERNAL_API_KEY not set in environment variables. API is unprotected!'); // In production, you might want to deny access if the key isn't set // return res.status(500).json({ message: 'Server configuration error' }); } // Allow request if key is not set (for local dev convenience), but log warning. // OR strictly enforce: if (!expectedApiKey || !apiKey || apiKey !== expectedApiKey) { ... } if (expectedApiKey && apiKey === expectedApiKey) { return next(); // API key is valid, proceed } else if (!expectedApiKey) { console.warn('INTERNAL_API_KEY not set, allowing request. THIS IS INSECURE FOR PRODUCTION.'); return next(); // Allow request if key isn't set (development convenience) } else { console.warn('Unauthorized API access attempt denied.'); return res.status(401).json({ message: 'Unauthorized: Invalid or missing API Key' }); } } module.exports = { authenticateApiKey };
- Why API Key? While not as robust as OAuth, a simple API key in the header provides a basic layer of security suitable for internal services or trusted clients. Ensure
INTERNAL_API_KEY
is a strong, random string.
- Why API Key? While not as robust as OAuth, a simple API key in the header provides a basic layer of security suitable for internal services or trusted clients. Ensure
-
Create Messaging Controller (
src/controllers/messagingController.js
): This controller handles the request validation and calls thesinchService
.// src/controllers/messagingController.js const sinchService = require('../services/sinchService'); async function handleBulkSmsRequest(req, res, next) { const { recipients, message, clientReference } = req.body; // --- Input Validation --- if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ message: 'Bad Request: "recipients" must be a non-empty array of phone numbers.' }); } if (!message || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ message: 'Bad Request: "message" must be a non-empty string.' }); } // Optional: Add more validation like max recipients, message length, etc. const MAX_RECIPIENTS_PER_BATCH = 1000; // Example limit (check Sinch documentation for actual limits) if (recipients.length > MAX_RECIPIENTS_PER_BATCH) { return res.status(400).json({ message: `Bad Request: Maximum recipients per batch is ${MAX_RECIPIENTS_PER_BATCH}.` }); } try { console.log(`Received bulk SMS request for ${recipients.length} recipients.`); const sinchResponse = await sinchService.sendBulkSms(recipients, message.trim(), clientReference); // Success response includes the batch ID from Sinch res.status(202).json({ // 202 Accepted is suitable as batch processing is asynchronous message: `Bulk SMS batch accepted for processing.`, batchId: sinchResponse.id, // Include the batch ID returned by Sinch details: sinchResponse // Optionally return the full Sinch response }); } catch (error) { // Pass the error to the centralized error handler next(error); } } module.exports = { handleBulkSmsRequest };
- Why
next(error)
? Instead of handling all error responses here, we pass errors to the centralizederrorHandler
middleware (created later) for consistent error formatting. - Why 202 Accepted? The Sinch API accepts the batch request, but the actual delivery happens asynchronously. 202 reflects that the request was accepted for processing.
- Why
-
Create Messaging Routes (
src/routes/messagingRoutes.js
): Define the/broadcast
endpoint and apply the authentication middleware.// src/routes/messagingRoutes.js const express = require('express'); const messagingController = require('../controllers/messagingController'); const { authenticateApiKey } = require('../middleware/authMiddleware'); const router = express.Router(); // POST /api/v1/messaging/broadcast // Requires 'x-api-key' header (unless INTERNAL_API_KEY is unset) // Body: { "recipients": ["+1...","+1..."], "message": "Your message", "clientReference": "optional-id-123" } router.post( '/broadcast', authenticateApiKey, // Apply API key authentication middleware messagingController.handleBulkSmsRequest ); module.exports = router;
-
Update
src/app.js
(Import Routes): EnsuremessagingRoutes
is imported and used insrc/app.js
(already done in Step 1.9). -
Test the API Endpoint: Restart your server (
npm run dev
). You can test usingcurl
or Postman. Replace placeholders with your actual values.# Replace YOUR_INTERNAL_API_KEY, YOUR_RECIPIENT_1, YOUR_RECIPIENT_2 with actual values # Ensure recipients are in E.164 format (e.g., +12025550181) # If INTERNAL_API_KEY is not set in .env, you can omit the -H "x-api-key: ..." line for testing curl -X POST http://localhost:3000/api/v1/messaging/broadcast \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_INTERNAL_API_KEY" \ -d '{ "recipients": ["YOUR_RECIPIENT_1", "YOUR_RECIPIENT_2"], "message": "Hello from the Sinch Bulk Broadcaster!", "clientReference": "test-broadcast-001" }'
Expected Success Response (JSON):
{ "message": "Bulk SMS batch accepted for processing.", "batchId": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "details": { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "to": ["YOUR_RECIPIENT_1", "YOUR_RECIPIENT_2"], "from": "YOUR_SINCH_NUMBER", "canceled": false, "body": "Hello from the Sinch Bulk Broadcaster!", "type": "mt_batch", "client_reference": "test-broadcast-001" } }
Expected Error Response (e.g., Missing/Invalid API Key - JSON):
{ "message": "Unauthorized: Invalid or missing API Key" }
Expected Error Response (e.g., Bad Request - JSON):
{ "message": "Bad Request: \"recipients\" must be a non-empty array of phone numbers." }
4. Integrating with necessary third-party services (Sinch)
Proper configuration and secure handling of Sinch credentials are vital.
-
Obtain Sinch Credentials:
- Log in to your Sinch Customer Dashboard (https://dashboard.sinch.com/).
- Navigate to APIs in the left-hand menu.
- Under SMS, find your active API configuration or create a new one.
- Note down the Service plan ID and API token. Treat the API token like a password – keep it confidential.
- Navigate to Numbers -> Your virtual numbers.
- Note down the phone number you want to use as the sender (
from
address). Ensure it's in E.164 format (e.g.,+12025550181
).
-
Configure Environment Variables (
.env
): Open your.env
file and replace the placeholders with your actual Sinch credentials and a secure internal API key.# .env # Server Configuration PORT=3000 # Sinch API Credentials SINCH_SERVICE_PLAN_ID=YOUR_ACTUAL_SERVICE_PLAN_ID # Paste from Sinch Dashboard SINCH_API_TOKEN=YOUR_ACTUAL_API_TOKEN # Paste from Sinch Dashboard SINCH_VIRTUAL_NUMBER=+12345678900 # Your Sinch number in E.164 format # Application Security INTERNAL_API_KEY=dJ8sK9pL3dRqZ7vN1xY5bA... # Generate a strong random string (or leave blank for dev)
SINCH_SERVICE_PLAN_ID
: Identifies your specific service plan with Sinch. Required in the API endpoint URL.SINCH_API_TOKEN
: Authenticates your requests to the Sinch API. Sent in theAuthorization: Bearer
header.SINCH_VIRTUAL_NUMBER
: The sender ID (phone number) that will appear on the recipient's device. Must be a number associated with your Sinch account.INTERNAL_API_KEY
: Used by our own API middleware (authenticateApiKey
) to protect the endpoint.
-
Secure Handling:
- Never commit
.env
to Git. Ensure.env
is listed in your.gitignore
file. - Use environment variables provided by your deployment platform (Heroku Config Vars, AWS Secrets Manager, etc.) in production instead of a
.env
file. - Rotate your Sinch API token periodically.
- Never commit
-
Fallback Mechanisms (Conceptual): While Sinch is generally reliable, consider these for extreme high availability (more advanced):
- Retry Logic: Implement retry logic within
sinchService.js
for transient network errors or specific Sinch 5xx errors (see Section 5). - Circuit Breaker: Use a pattern/library (like
opossum
) to temporarily stop sending requests to Sinch if a high error rate is detected, preventing cascading failures. - Alternative Provider: For critical systems, you might have a secondary SMS provider configured, switching over if Sinch experiences a prolonged outage. This adds significant complexity. For most use cases, robust retry logic is sufficient.
- Retry Logic: Implement retry logic within
5. Implementing proper error handling, logging, and retry mechanisms
Robust error handling and logging are essential for diagnosing issues in production.
-
Centralized Error Handler (
src/middleware/errorHandler.js
): Create a middleware to catch errors passed vianext(error)
and format a consistent JSON response.// src/middleware/errorHandler.js function errorHandler(err, req, res, next) { console.error('--- Unhandled Error ---'); console.error('Timestamp:', new Date().toISOString()); console.error('Request Path:', req.path); console.error('Request Method:', req.method); // Avoid logging sensitive request body details in production unless necessary and filtered // console.error('Request Body:', req.body); console.error('Error Stack:', err.stack || err); console.error('--- End Unhandled Error ---'); // Default to 500 Internal Server Error let statusCode = err.statusCode || 500; let message = err.message || 'Internal Server Error'; // Customize based on error type if needed if (err.message.startsWith('Sinch API request failed')) { // You might want to map specific Sinch errors to different client-facing messages/codes statusCode = 502; // Bad Gateway - indicates upstream failure message = 'Failed to communicate with SMS provider.'; } else if (err.message === 'Sinch service not configured.') { statusCode = 503; // Service Unavailable message = 'SMS service is temporarily unavailable due to configuration issues.'; } else if (err.message.includes('Invalid E.164 phone number format')) { statusCode = 400; // Bad Request message = err.message; // Pass the specific message back } else if (err.message === 'Recipient list cannot be empty.' || err.message === 'Message body cannot be empty.') { statusCode = 400; // Bad Request message = err.message; } // Add more specific error handling as needed res.status(statusCode).json({ message: message, // Optionally include error code or type in production for easier tracking // errorType: err.constructor.name, // Only include stack in development ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); } module.exports = { errorHandler };
- Why Centralized? Ensures all errors are logged consistently and return a standardized JSON response format to the client, hiding potentially sensitive stack traces in production.
-
Update
src/app.js
(Import Error Handler): Ensure theerrorHandler
is imported and registered last in the middleware chain insrc/app.js
(already done in Step 1.9). -
Logging:
- We're currently using
console.log
andconsole.error
. This is acceptable for simple applications or development. - Production Logging: For production, use a dedicated logging library like
winston
orpino
. These offer:- Log Levels: (debug, info, warn, error) to control verbosity.
- Structured Logging: Output logs in JSON format for easier parsing by log analysis tools (e.g., Datadog, Splunk, ELK stack).
- Transports: Send logs to files, databases, or external services.
- Example (Conceptual Winston Setup):
// Example: src/config/logger.js (Conceptual) const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() // Structured logging ), defaultMeta: { service: 'sms-broadcaster' }, // Add service context transports: [ // Log to console (suitable for containerized environments) new winston.transports.Console({ format: winston.format.simple(), // Use simple format for console if preferred }), // Example: Log errors to a separate file // new winston.transports.File({ filename: 'error.log', level: 'error' }), // Example: Log all levels to another file // new winston.transports.File({ filename: 'combined.log' }) ], }); // If we're not in production then log to the `console` with the format: // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } module.exports = logger; // Then replace console.log/error with logger.info/warn/error throughout the app // e.g., const logger = require('./config/logger'); logger.info('Server started');
- We're currently using
-
Retry Mechanisms (Basic Implementation in
sinchService.js
): Let's add a simple retry logic for potential network issues or transient Sinch errors.// src/services/sinchService.js (Modify the sendBulkSms function) const fetch = require('node-fetch'); const SINCH_API_BASE_URL = 'https://us.sms.api.sinch.com/xms/v1'; // Or your region // --- Add Retry Configuration --- const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY_MS = 500; // Start with 500ms delay // Helper function for async delay const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function sendBulkSms(recipients, messageBody, clientReference = null) { const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID; const apiToken = process.env.SINCH_API_TOKEN; const sinchNumber = process.env.SINCH_VIRTUAL_NUMBER; // --- Credential & Input Validation (as before) --- if (!servicePlanId || !apiToken || !sinchNumber) { console.error('Error: Sinch API credentials or virtual number not configured in .env'); throw new Error('Sinch service not configured.'); // Non-retryable config error } if (!recipients || recipients.length === 0) { throw new Error('Recipient list cannot be empty.'); // Non-retryable input error } if (!messageBody) { throw new Error('Message body cannot be empty.'); // Non-retryable input error } const invalidNumbers = recipients.filter(num => !/^\+[1-9]\d{1,14}$/.test(num)); if (invalidNumbers.length > 0) { // Non-retryable input error throw new Error(`Invalid E.164 phone number format detected: ${invalidNumbers.join(', ')}`); } // --- End Validation --- const endpoint = `${SINCH_API_BASE_URL}/${servicePlanId}/batches`; const payload = { to: recipients, from: sinchNumber, body: messageBody, ...(clientReference && { client_reference: clientReference }) }; console.log(`Sending bulk SMS to ${recipients.length} recipients via Sinch...`); // --- Add Retry Loop --- let attempts = 0; while (attempts < MAX_RETRIES) { attempts++; try { console.log(`Sinch API request attempt ${attempts}/${MAX_RETRIES}...`); const response = await fetch(endpoint_ { method: 'POST'_ headers: { 'Content-Type': 'application/json'_ 'Authorization': `Bearer ${apiToken}` }_ body: JSON.stringify(payload)_ timeout: 10000 // Add a request timeout (e.g._ 10 seconds) }); // --- Handle Non-Retryable Client Errors (4xx) --- if (response.status >= 400 && response.status < 500) { const responseBody = await response.json().catch(() => ({})); // Attempt to parse JSON console.error(`Sinch API Client Error (${response.status}) on attempt ${attempts}:`, responseBody); // Throw specific error for non-retryable client issues throw new Error(`Sinch API request failed with status ${response.status}: ${responseBody.error?.message || response.statusText} (Non-Retryable Client Error)`); } // --- Handle Server Errors (5xx) or Network Issues (potentially retryable) --- if (!response.ok) { // Catches 5xx errors, network errors might throw before this const responseBody = await response.json().catch(() => ({})); console.warn(`Sinch API Server Error (${response.status}) on attempt ${attempts}:`, responseBody); // Throw a temporary error to trigger retry if attempts remain throw new Error(`Sinch API server error ${response.status}`); // Generic error for retry } // --- Success --- const responseBody = await response.json(); console.log('Sinch API Response:', responseBody); return responseBody; // Success, exit the loop and function } catch (error) { console.warn(`Attempt ${attempts} failed: ${error.message}`); // Check if it's the last attempt OR a non-retryable error type const isNonRetryable = error.message.includes('Non-Retryable Client Error') || error.message === 'Sinch service not configured.' || // Config errors error.message.includes('Invalid E.164') || // Input errors error.message.includes('cannot be empty'); // Input errors if (attempts >= MAX_RETRIES || isNonRetryable) { console.error(`Giving up after ${attempts} attempts or due to non-retryable error.`); // Re-throw the *original* specific error if it was non-retryable // Otherwise, throw a generic failure message after retries throw isNonRetryable ? error : new Error(`Failed to send SMS via Sinch after ${attempts} attempts.`); } // --- Calculate Delay (Exponential Backoff) --- // Add jitter: random small variation to delay to prevent thundering herd const jitter = Math.random() * INITIAL_RETRY_DELAY_MS * 0.5; const delayTime = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempts - 1) + jitter; console.log(`Retrying in approximately ${Math.round(delayTime)}ms...`); await delay(delayTime); } } // This line should theoretically not be reached due to throws in the loop throw new Error('Sinch request failed unexpectedly after retry loop.'); } module.exports = { sendBulkSms };
- Why Retry? Network glitches or temporary Sinch issues can occur. Retrying increases the chance of successful delivery without manual intervention.
- Why Exponential Backoff with Jitter? Waiting longer between retries (
INITIAL_RETRY_DELAY_MS * Math.pow(2, attempts - 1)
) prevents overwhelming the Sinch API during widespread issues. Adding jitter (a small random variation) helps prevent multiple instances of your service from retrying simultaneously. - Why Differentiate Errors? We only retry on server errors (5xx) or network timeouts/errors. Client errors (4xx like invalid number format, bad credentials) or configuration/input errors identified before the request are not retryable, so we fail fast.