Target Audience: Developers familiar with Node.js and REST APIs, looking to build a scalable system for sending marketing messages (primarily SMS and potentially email) via AWS SNS using the Fastify framework.
Prerequisites:
Node.js (v18 or later recommended)
npm or yarn
An AWS account with permissions to manage SNS and IAM.
Basic familiarity with command-line/terminal usage.
Optional: Docker for containerization, Postman or curl for API testing.
Building Scalable Marketing Campaigns with Fastify and AWS SNS
This guide details how to build a robust API using Fastify to manage and send marketing messages through AWS Simple Notification Service (SNS). We'll cover everything from project setup and core SNS interactions to API design, security, deployment, and monitoring.
Project Goals:
Create a Fastify API to manage SNS topics relevant to marketing campaigns.
Implement functionality to subscribe users (via SMS or email) to these topics.
Enable sending bulk messages to subscribed users via SNS topics.
Enable sending direct SMS messages for targeted communications.
Ensure the system is secure, scalable, handles errors gracefully, and is ready for production deployment.
Why these technologies?
Fastify: A high-performance, low-overhead Node.js web framework ideal for building efficient APIs. Its plugin architecture makes integration straightforward.
AWS SNS: A fully managed pub/sub messaging service that handles the complexities of message delivery across various protocols (SMS, email, push notifications, etc.) at scale. It's cost-effective and reliable.
fastify-aws-sns Plugin: Simplifies interaction with the AWS SNS API directly within the Fastify application context. (Note: Plugin functionality should be verified against its documentation, as wrappers can sometimes have limitations or lag behind the underlying SDK.)
By the end of this guide, you will have a functional Fastify API capable of:
Creating and listing SNS topics.
Subscribing phone numbers and email addresses to topics.
Handling the SNS subscription confirmation workflow.
Publishing messages to topics.
Sending direct SMS messages.
Basic security, logging, and error handling implemented.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1. Initialize Project
Open your terminal and create a new project directory:
Bash
mkdir fastify-sns-campaigns
cd fastify-sns-campaigns
npm init -y
1.2. Install Dependencies
We need Fastify, the SNS plugin, a tool to load environment variables, and the core AWS SDK (which fastify-aws-sns uses under the hood, but explicitly installing ensures compatibility and allows direct SDK usage if needed).
Create a .env file in the project root. This file will hold your AWS credentials and other configuration. Never commit this file to version control.
.env
# .env
# AWS Credentials - Obtain from IAM User (See Section 4)
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
AWS_REGION=us-east-1 # Or your preferred AWS region
# Default SNS Topic (Optional - can be overridden via API)
# AWS_TOPIC_NAME=my-marketing-topic
# API Configuration
API_PORT=3000
API_HOST=127.0.0.1
API_KEY=your-secret-api-key # Simple API key for auth (See Section 7 - Use robust auth in production!)
Why .env? It keeps sensitive credentials and configuration separate from your code, making it easier to manage different environments (development, staging, production) and enhancing security.
1.6. Basic Server Setup (server.js)
This file initializes Fastify, loads environment variables, registers plugins and routes, and starts the server.
Why logger: true? Enables detailed logging of requests, responses, and errors, crucial for debugging.
Why 0.0.0.0 in production? Necessary for containerized environments (like Docker) to accept connections from outside the container.
2. Implementing Core Functionality (SNS Plugin)
Now, let's integrate the fastify-aws-sns plugin.
2.1. Register the SNS Plugin (plugins/sns.js)
This file registers the fastify-aws-sns plugin, making SNS functions available on the Fastify instance (e.g., fastify.snsTopics, fastify.snsMessage). The plugin automatically picks up AWS credentials from environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) if they follow the standard naming convention.
JavaScript
// plugins/sns.js'use strict';
const fp = require('fastify-plugin');
const fastifyAwsSns = require('fastify-aws-sns');
asyncfunctionsnsPlugin(fastify, options) {
// The plugin automatically uses AWS credentials from environment variables// (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)// or IAM roles if running on EC2/ECS/Lambda.
fastify.register(fastifyAwsSns);
fastify.log.info('AWS SNS Plugin registered');
}
module.exports = fp(snsPlugin, {
name: 'snsPlugin',
// Specify dependencies if needed, e.g., ['configPlugin']
});
Why fastify-plugin? It prevents Fastify from creating separate encapsulated contexts for the plugin, ensuring that the fastify.sns* decorators are available globally across your application routes.
2.2. Understanding Plugin Methods
The fastify-aws-sns plugin exposes several methods categorized under namespaces attached to the fastify instance. These typically include:
fastify.snsTopics: For managing topics (create, list, delete, get/set attributes).
fastify.snsMessage: For publishing messages to a topic.
fastify.snsSubscriptions: For managing subscriptions (list, subscribe different protocols like email/SMS/HTTP, confirm, unsubscribe).
fastify.snsSMS: For SMS-specific actions (publish direct SMS, check opt-out status, manage SMS attributes).
Important: The exact methods and their behavior depend on the plugin version. Always consult the fastify-aws-sns documentation or source code to confirm available functionality and parameters. If the plugin lacks a specific feature (e.g., advanced pagination, batching), you may need to use the AWS SDK directly (see Section 9.1 - Note: Section 9.1 is mentioned but not provided in the original text).
We will use these methods within our API routes in the next section, assuming they function as described.
3. Building the API Layer
We'll define RESTful endpoints to interact with SNS functionalities.
3.1. Basic Authentication Hook
For simplicity, we'll use a basic API key check. In production, use a more robust method like JWT or OAuth (see Section 7 - Note: Section 7 is mentioned but not provided in the original text).
Add this hook at the beginning of your main routes file (routes/index.js).
Define the routes within an async function exported by the module.
JavaScript
// routes/index.js (Continued)const { SNSClient, ListTopicsCommand, ListSubscriptionsByTopicCommand } = require(""@aws-sdk/client-sns""); // For direct SDK calls if neededasyncfunctionroutes(fastify, options) {
// Apply the authentication hook to all routes defined in this plugin
fastify.addHook('preHandler', authenticate);
// Initialize SNS Client for direct SDK calls if needed// Credentials and region are picked up from environment by defaultconst snsClient = newSNSClient({});
fastify.log.info('Registering API routes...');
// --- Topic Management ---// POST /api/topics - Create a new SNS topic
fastify.post('/topics', {
schema: {
body: {
type: 'object',
required: ['topicName'],
properties: {
topicName: { type: 'string', minLength: 1, maxLength: 256, pattern: '^[a-zA-Z0-9_-]+$' },
isFifo: { type: 'boolean', default: false }, // Optional: For FIFO topicstags: { type: 'object' } // Optional: { key: value, ... }
}
},
response: {
201: {
type: 'object',
properties: {
TopicArn: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const { topicName, isFifo, tags } = request.body;
const attributes = {};
if (isFifo) {
attributes.FifoTopic = 'true';
// FIFO topics require .fifo suffixif (!topicName.endsWith('.fifo')) {
reply.code(400).send({ error: 'Bad Request', message: 'FIFO topic names must end with .fifo' });
return;
}
// ContentBasedDeduplication is often useful for FIFO
attributes.ContentBasedDeduplication = 'true';
}
const params = {
topic: topicName, // Plugin uses 'topic' for nameattributes: attributes,
tags: tags ? Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) : undefined
};
try {
fastify.log.info(`Creating SNS topic: ${topicName}`);
// Assuming fastify.snsTopics.create exists and works as expectedconst result = await fastify.snsTopics.create(params);
reply.code(201).send({ TopicArn: result.TopicArn });
} catch (error) {
fastify.log.error({ error, params }, 'Error creating SNS topic');
reply.code(500).send({ error: 'Failed to create topic', message: error.message });
}
});
// GET /api/topics - List existing SNS topics
fastify.get('/topics', {
schema: {
query: { // Add query schema for pagination tokentype: 'object',
properties: {
nextToken: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
Topics: {
type: 'array',
items: {
type: 'object',
properties: {
TopicArn: { type: 'string' }
}
}
},
NextToken: { type: ['string', 'null'] } // For pagination
}
}
}
}
}, async (request, reply) => {
const { nextToken } = request.query;
try {
fastify.log.info(`Listing SNS topics (nextToken: ${nextToken})`);
// Use direct SDK for reliable paginationconst command = newListTopicsCommand({ NextToken: nextToken });
const result = await snsClient.send(command);
reply.send({
Topics: result.Topics || [],
NextToken: result.NextToken || null
});
// --- Alternative using plugin (if pagination is not needed or verified to work): ---// const result = await fastify.snsTopics.list({}); // Check plugin docs for pagination support// reply.send(result);
} catch (error) {
fastify.log.error({ error }, 'Error listing SNS topics');
reply.code(500).send({ error: 'Failed to list topics', message: error.message });
}
});
// --- Subscription Management ---// POST /api/topics/:topicArn/subscriptions - Subscribe an endpoint
fastify.post('/topics/:topicArn/subscriptions', {
schema: {
params: {
type: 'object',
required: ['topicArn'],
properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
},
body: {
type: 'object',
required: ['protocol', 'endpoint'],
properties: {
protocol: { type: 'string', enum: ['sms', 'email', 'email-json'] },
endpoint: { type: 'string' } // Phone number (E.164) or email address
}
},
response: {
// SNS subscription requires confirmation (except sometimes SMS depending on region/settings)202: {
type: 'object',
properties: {
message: { type: 'string' },
SubscriptionArn: { type: 'string' } // Often 'pending confirmation'
}
}
}
}
}, async (request, reply) => {
const { topicArn } = request.params;
const { protocol, endpoint } = request.body;
let subscribePromise;
// Plugin parameter names might differ slightly from SDK - check plugin docsconst params = { topicArn };
try {
fastify.log.info(`Subscribing ${endpoint} (${protocol}) to topic ${topicArn}`);
switch (protocol) {
case'sms':
// Validate E.164 format (+ followed by country code and number)if (!/^\+[1-9]\d{1,14}$/.test(endpoint)) {
reply.code(400).send({ error: 'Bad Request', message: 'Invalid phone number format. Use E.164 (e.g., +12125551234).' });
return;
}
params.phoneNumber = endpoint; // Assuming plugin uses 'phoneNumber'// Verify method name and parameters with plugin documentation
subscribePromise = fastify.snsSubscriptions.setBySMS(params);
break;
case'email':
params.email = endpoint; // Assuming plugin uses 'email'// Verify method name and parameters with plugin documentation
subscribePromise = fastify.snsSubscriptions.setByEMail(params);
break;
case'email-json':
params.email = endpoint; // Assuming plugin uses 'email'// Verify method name and parameters with plugin documentation
subscribePromise = fastify.snsSubscriptions.setByEMailJSON(params);
break;
default:
reply.code(400).send({ error: 'Bad Request', message: `Unsupported protocol: ${protocol}` });
return;
}
const result = await subscribePromise;
// Note: Email subscriptions return a SubscriptionArn of 'pending confirmation'// and require confirmation via a link sent to the email.// SMS might auto-confirm in some regions/setups or also pend.
reply.code(202).send({
message: `Subscription request sent for ${endpoint}. Confirmation might be required.`,
SubscriptionArn: result.SubscriptionArn || 'pending confirmation'
});
} catch (error) {
fastify.log.error({ error, params }, 'Error subscribing endpoint');
reply.code(500).send({ error: 'Failed to subscribe', message: error.message });
}
});
// GET /api/topics/:topicArn/subscriptions - List subscriptions for a topic
fastify.get('/topics/:topicArn/subscriptions', {
schema: {
params: {
type: 'object',
required: ['topicArn'],
properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
},
query: { // Add query schema for pagination tokentype: 'object',
properties: {
nextToken: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
Subscriptions: {
type: 'array',
items: {
type: 'object',
properties: {
SubscriptionArn: { type: 'string' },
Owner: { type: 'string' },
Protocol: { type: 'string' },
Endpoint: { type: 'string' },
TopicArn: { type: 'string' }
}
}
},
NextToken: { type: ['string', 'null'] }
}
}
}
}
}, async (request, reply) => {
const { topicArn } = request.params;
const { nextToken } = request.query;
try {
fastify.log.info(`Listing subscriptions for topic ${topicArn} (nextToken: ${nextToken})`);
// Use direct SDK for reliable paginationconst command = newListSubscriptionsByTopicCommand({ TopicArn: topicArn, NextToken: nextToken });
const result = await snsClient.send(command);
reply.send({
Subscriptions: result.Subscriptions || [],
NextToken: result.NextToken || null
});
// --- Alternative using plugin (if pagination is not needed or verified to work): ---// const result = await fastify.snsSubscriptions.list({ topicArn }); // Check plugin docs for pagination support// reply.send(result);
} catch (error) {
fastify.log.error({ error, topicArn }, 'Error listing subscriptions');
reply.code(500).send({ error: 'Failed to list subscriptions', message: error.message });
}
});
// --- Message Publishing ---// POST /api/topics/:topicArn/publish - Publish a message to a topic
fastify.post('/topics/:topicArn/publish', {
schema: {
params: {
type: 'object',
required: ['topicArn'],
properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } }
},
body: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', minLength: 1 },
subject: { type: 'string', maxLength: 100 }, // Primarily for emailmessageAttributes: { type: 'object' } // Optional: { key: { DataType: 'String', StringValue: 'value' }, ...}
}
},
response: {
200: {
type: 'object',
properties: {
MessageId: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const { topicArn } = request.params;
const { message, subject, messageAttributes } = request.body;
// Plugin parameter names might differ slightly from SDK - check plugin docsconst params = {
topicArn,
message,
subject, // Subject is ignored by SMS
messageAttributes
};
try {
fastify.log.info(`Publishing message to topic ${topicArn}`);
// Assuming fastify.snsMessage.publish exists and works as expectedconst result = await fastify.snsMessage.publish(params);
reply.send({ MessageId: result.MessageId });
} catch (error) {
fastify.log.error({ error, params }, 'Error publishing message to topic');
reply.code(500).send({ error: 'Failed to publish message', message: error.message });
}
});
// POST /api/sms/publish - Send a direct SMS message
fastify.post('/sms/publish', {
schema: {
body: {
type: 'object',
required: ['phoneNumber', 'message'],
properties: {
phoneNumber: { type: 'string', pattern: '^\+[1-9]\d{1,14}$' }, // E.164 formatmessage: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SNS limitssenderId: { type: 'string', maxLength: 11 }, // Optional: Custom Sender ID (if supported)messageType: { type: 'string', enum: ['Promotional', 'Transactional'], default: 'Promotional' }
}
},
response: {
200: {
type: 'object',
properties: {
MessageId: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const { phoneNumber, message, senderId, messageType } = request.body;
// Plugin parameter names might differ slightly from SDK - check plugin docsconst params = {
phoneNumber,
message,
messageAttributes: {} // Note: Verify if plugin handles attributes directly in publish or requires separate setAttributes call
};
if (senderId) {
params.messageAttributes['AWS.SNS.SMS.SenderID'] = {
DataType: 'String',
StringValue: senderId
};
}
params.messageAttributes['AWS.SNS.SMS.SMSType'] = {
DataType: 'String',
StringValue: messageType
};
try {
fastify.log.info(`Sending direct SMS to ${phoneNumber}`);
// Assuming fastify.snsSMS.publish exists and handles messageAttributes correctly.// If not, you might need to call a separate setAttributes method via plugin or SDK first.const result = await fastify.snsSMS.publish(params);
reply.send({ MessageId: result.MessageId });
} catch (error) {
fastify.log.error({ error, params }, 'Error sending direct SMS');
reply.code(500).send({ error: 'Failed to send SMS', message: error.message });
}
});
fastify.log.info('API routes registered.');
}
module.exports = routes;
Why Schema Validation? Ensures incoming requests have the correct structure and data types before your handler logic runs, preventing errors and improving security. Fastify handles this efficiently.
Why async/await? Simplifies handling asynchronous operations like calling the AWS API.
Why log errors? Essential for debugging production issues. Logging includes the error object and relevant parameters.
3.3. API Testing Examples (curl)
Replace placeholders like YOUR_API_KEY, YOUR_TOPIC_ARN, +12223334444 with actual values. Use single quotes around JSON data for curl on most shells.
Create Topic:
Bash
curl -X POST http://localhost:3000/api/topics \
-H ""Content-Type: application/json"" \
-H ""x-api-key: YOUR_API_KEY"" \
-d '{ ""topicName"": ""my-new-campaign"" }'# Expected: 201 Created with { ""TopicArn"": ""arn:aws:sns:..."" }
List Topics:
Bash
curl http://localhost:3000/api/topics -H ""x-api-key: YOUR_API_KEY""# Expected: 200 OK with { ""Topics"": [...] }
curl -X POST http://localhost:3000/api/sms/publish \
-H ""Content-Type: application/json"" \
-H ""x-api-key: YOUR_API_KEY"" \
-d '{ ""phoneNumber"": ""+12223334444"", ""message"": ""Direct message test"" }'# Expected: 200 OK with { ""MessageId"": ""..."" }# Check the phone number
4. Integrating with AWS SNS (Credentials & IAM)
Securely connecting to AWS is paramount.
4.1. Create an IAM User
It's best practice to create a dedicated IAM user with least privilege access.
Go to the AWS Management Console -> IAM.
Navigate to Users -> Add users.
Enter a User name (e.g., fastify-sns-api-user).
Select Provide user access to the AWS Management Console - Optional. If selected, set a password.
Select Access key - Programmatic access. This is crucial for API interaction. Click Next.
Set permissions: Choose Attach policies directly.
Search for and select the AmazonSNSFullAccess policy. Note: For stricter security, create a custom policy granting only the specific SNS actions needed (e.g., sns:CreateTopic, sns:ListTopics, sns:Subscribe, sns:Publish, sns:ListSubscriptionsByTopic, sns:SetSMSAttributes, sns:CheckIfPhoneNumberIsOptedOut, sns:GetSMSSandboxAccountStatus etc.). Start with AmazonSNSFullAccess for simplicity during development, but refine for production.
Click Next: Tags (Optional: Add tags for organization).
Click Next: Review.
Click Create user.
IMPORTANT: On the final screen, copy the Access key ID and Secret access key. Store them securely (e.g., in your .env file locally, or a secrets manager in production). You won't be able to see the secret key again.
4.2. Configure Credentials
Store the copied credentials securely in your .env file for local development:
.env
# .env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE # Replace with your Access Key ID
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # Replace with your Secret Access Key
AWS_REGION=us-east-1 # Ensure this matches the region you want to use SNS in
AWS_ACCESS_KEY_ID: Identifies the IAM user.
AWS_SECRET_ACCESS_KEY: The secret password for the user. Treat this like a password.
AWS_REGION: The AWS region where your SNS topics and resources will reside (e.g., us-east-1, eu-west-1). SNS is region-specific.
The fastify-aws-sns plugin (and the underlying AWS SDK) will automatically detect and use these environment variables. If deploying to AWS services like EC2, ECS, or Lambda, you should use IAM Roles instead, which is more secure as credentials aren't hardcoded or stored in files.
5. Error Handling, Logging, and Retry Mechanisms
Robust applications need to handle failures gracefully.
5.1. Consistent Error Handling
Fastify allows defining a global error handler. We'll augment the existing logging within routes.
Add this to server.js before start():
JavaScript
// server.js (before start())
fastify.setErrorHandler(function (error, request, reply) {
// Log the error
fastify.log.error({
request: {
method: request.method,
url: request.url,
headers: request.headers,
// Avoid logging sensitive data from body in productionbody: process.env.NODE_ENV !== 'production' ? request.body : undefined,
query: request.query,
params: request.params,
},
error: {
message: error.message,
stack: error.stack,
code: error.code, // AWS SDK errors often have codesstatusCode: error.statusCode,
},
}, 'Unhandled error occurred');
// Send generic error response in productionif (process.env.NODE_ENV === 'production' && (!error.statusCode || error.statusCode >= 500)) {
reply.status(500).send({ error: 'Internal Server Error', message: 'An unexpected error occurred' });
return;
}
// Send detailed error response in development or for client errors (4xx)const statusCode = error.statusCode >= 400 ? error.statusCode : 500;
reply.status(statusCode).send({
error: error.name || 'Internal Server Error',
message: error.message || 'An unexpected error occurred',
code: error.code, // Include code for debugging
});
});
Why setErrorHandler? Catches unhandled errors thrown within route handlers or plugins, ensuring a consistent error response format and logging.
Why log request details? Helps immensely in debugging by showing the exact request that caused the error (be careful with sensitive body data).
AWS Error Codes: AWS SDK errors often include specific codes (e.g., InvalidParameterValue, AuthorizationError, ThrottlingException) which are useful for diagnostics.
5.2. Logging
Fastify's built-in Pino logger (logger: true) is efficient.
Levels: By default, it logs info and above. You can configure the level: logger: { level: 'debug' }.
Formats: Logs are typically JSON, suitable for log aggregation systems (CloudWatch Logs, Datadog, Splunk).
Context: We added specific logging within routes (fastify.log.info, fastify.log.error) to show application-specific actions and errors.
5.3. Retry Mechanisms
SNS Delivery Retries: AWS SNS automatically handles retries for delivering messages to subscribed endpoints (e.g., if an email server is temporarily down or an SMS gateway is busy). This is configured within SNS itself (Delivery policy).
API Call Retries: What if the API call to AWS SNS fails due to network issues or transient AWS problems (like throttling)?
Simple Approach: For critical operations like publishing, you could wrap the fastify.sns*.publish() or direct SDK call in a simple retry loop with exponential backoff.
Library Approach: Use a library like async-retry for more sophisticated retry logic.
Example using async-retry (install npm i async-retry):
JavaScript
// Example within a route handler (e.g., publish)const retry = require('async-retry');
// ... inside POST /api/topics/:topicArn/publish handler ...try {
const result = awaitretry(async (bail, attempt) => {
fastify.log.info(`Publishing message to topic ${topicArn}, attempt ${attempt}`);
try {
// Use either the plugin or direct SDK call herereturnawait fastify.snsMessage.publish(params);
// OR: return await snsClient.send(new PublishCommand(sdkParams));
} catch (error) {
// Don't retry on client errors (4xx) like invalid parameters// AWS SDK v3 errors might not have statusCode directly, check $metadataconst statusCode = error.$metadata?.httpStatusCode || error.statusCode;
if (statusCode >= 400 && statusCode < 500) {
// Give up on client errorsbail(newError(`Non-retriable error (${statusCode}): ${error.message}`));
return; // Important: return after bail
}
// Throw other errors (like 5xx, network errors) to trigger retrythrow error;
}
}, {
retries: 3, // Number of retriesfactor: 2, // Exponential backoff factorminTimeout: 1000, // Initial delay msonRetry: (error, attempt) => {
fastify.log.warn(`Retrying publish operation (attempt ${attempt}) due to error: ${error.message}`);
}
});
reply.send({ MessageId: result.MessageId });
} catch (error) {
// This catches errors after retries have failed or non-retriable errors
fastify.log.error({ error, params }, 'Error publishing message to topic after retries');
// Use the status code from the bailed error if availableconst finalStatusCode = error.originalError?.$metadata?.httpStatusCode || error.originalError?.statusCode || 500;
reply.code(finalStatusCode).send({ error: 'Failed to publish message', message: error.message });
}
// ... rest of handler ...
Frequently asked questions
You can send SMS messages using a Fastify API integrated with AWS SNS. This involves setting up routes within your Fastify application to handle requests, using the fastify-aws-sns plugin to interact with the SNS service, and configuring proper AWS credentials. The API can then send messages directly via SNS or publish them to topics for subscribers.
AWS SNS (Simple Notification Service) is used to manage the sending of marketing messages like SMS and email. It's a pub/sub messaging service that handles delivery across various protocols, making it a scalable and cost-effective solution for reaching users. The article details how to integrate SNS with a Fastify API for sending marketing campaigns.
Fastify is a high-performance Node.js web framework ideal for building efficient APIs due to its low overhead and plugin architecture. The article demonstrates using Fastify with AWS SNS to create a scalable marketing message system, leveraging Fastify's speed and ease of integration.
Use the fastify-aws-sns plugin when building a Fastify application that needs to interact with AWS SNS. This plugin simplifies interactions with the AWS SNS API within your Fastify application context. Be sure to check the plugin documentation for compatibility.
First, initialize a Node.js project and install Fastify, fastify-aws-sns, dotenv, and @aws-sdk/client-sns. Create the recommended project structure with designated directories for routes and plugins. Configure AWS credentials and API settings in a .env file, and define the basic server setup in server.js.
The .env file stores environment variables, including sensitive data like AWS credentials and API keys, separate from your codebase. This enhances security and makes managing different environments (development, staging, production) easier, since you shouldn't commit this file to version control.
In the AWS Management Console, go to IAM, add a new user with programmatic access, and attach the AmazonSNSFullAccess policy (or a custom policy with least privilege for production). Save the generated Access Key ID and Secret Access Key securely, as these will be used in your .env file for authentication.
You'll need your AWS Access Key ID, Secret Access Key, and the AWS Region. These are stored in the .env file and automatically loaded by the fastify-aws-sns plugin and the AWS SDK. For production deployments on AWS services, using IAM roles is recommended over access keys.
Implement Fastify's setErrorHandler to catch unhandled errors, log detailed error information including request context and error codes, and send appropriate error responses. For transient AWS errors, use retry mechanisms with exponential backoff, potentially using a library like async-retry.
Schema validation ensures that incoming requests to your Fastify API conform to expected data structures and types, which improves security by preventing unexpected input and helps catch errors early in the request handling process. The provided code examples include route schemas for validation.
Use the /api/topics/:topicArn/publish route with a POST request, providing the topicArn as a parameter and the message body in JSON format. Ensure your request headers include the API key for authentication. The message body can include the actual message content, an optional subject (mainly for emails), and custom message attributes.
Make a POST request to /api/topics/:topicArn/subscriptions, including the topicArn, protocol (sms, email, email-json), and the endpoint (phone number or email address). The phone numbers should be in E.164 format. Remember email subscriptions usually require confirmation via a link sent to the provided address.
The article recommends using Node.js version 18 or later when building Fastify applications for marketing campaigns with AWS SNS. This is to ensure compatibility with the latest features and best practices in Fastify and related libraries.
The provided code demonstrates creating topics with a POST request to /api/topics and listing topics with a GET request to the same endpoint. It also shows how to manage subscriptions by adding and listing users via specified routes, including handling confirmation workflows.
IAM roles are more secure than storing access keys in files like .env because they provide temporary credentials that are automatically rotated, reducing the risk of compromise. When deploying your Fastify application to AWS services like EC2, ECS, or Lambda, configure your instances to use IAM roles.
Target Audience: Developers familiar with Node.js and REST APIs, looking to build a scalable system for sending marketing messages (primarily SMS and potentially email) via AWS SNS using the Fastify framework.
Prerequisites:
curlfor API testing.Building Scalable Marketing Campaigns with Fastify and AWS SNS
This guide details how to build a robust API using Fastify to manage and send marketing messages through AWS Simple Notification Service (SNS). We'll cover everything from project setup and core SNS interactions to API design, security, deployment, and monitoring.
Project Goals:
Why these technologies?
fastify-aws-snsPlugin: Simplifies interaction with the AWS SNS API directly within the Fastify application context. (Note: Plugin functionality should be verified against its documentation, as wrappers can sometimes have limitations or lag behind the underlying SDK.)System Architecture:
Expected Outcome:
By the end of this guide, you will have a functional Fastify API capable of:
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1. Initialize Project
Open your terminal and create a new project directory:
mkdir fastify-sns-campaigns cd fastify-sns-campaigns npm init -y1.2. Install Dependencies
We need Fastify, the SNS plugin, a tool to load environment variables, and the core AWS SDK (which
fastify-aws-snsuses under the hood, but explicitly installing ensures compatibility and allows direct SDK usage if needed).fastify: The core web framework.fastify-aws-sns: The plugin for easy SNS integration.dotenv: Loads environment variables from a.envfile intoprocess.env.@aws-sdk/client-sns: The official AWS SDK v3 for SNS (provides underlying functionality).1.3. Project Structure
Create the following basic structure:
1.4. Configure
.gitignoreCreate a
.gitignorefile to prevent committing sensitive information and unnecessary files:1.5. Environment Variables (
.env)Create a
.envfile in the project root. This file will hold your AWS credentials and other configuration. Never commit this file to version control..env? It keeps sensitive credentials and configuration separate from your code, making it easier to manage different environments (development, staging, production) and enhancing security.1.6. Basic Server Setup (
server.js)This file initializes Fastify, loads environment variables, registers plugins and routes, and starts the server.
// server.js 'use strict'; // Load environment variables from .env file require('dotenv').config(); // Import Fastify const fastify = require('fastify')({ logger: true, // Enable Fastify's built-in logger }); // Register custom plugins (SNS integration) fastify.register(require('./plugins/sns')); // Register API routes fastify.register(require('./routes/index'), { prefix: '/api' }); // Prefix all routes with /api // Health check route fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // Run the server const start = async () => { try { const port = process.env.API_PORT || 3000; const host = process.env.NODE_ENV === 'production' ? '0.0.0.0' : process.env.API_HOST || '127.0.0.1'; await fastify.listen({ port: parseInt(port, 10), host }); fastify.log.info(`Server listening on ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();logger: true? Enables detailed logging of requests, responses, and errors, crucial for debugging.0.0.0.0in production? Necessary for containerized environments (like Docker) to accept connections from outside the container.2. Implementing Core Functionality (SNS Plugin)
Now, let's integrate the
fastify-aws-snsplugin.2.1. Register the SNS Plugin (
plugins/sns.js)This file registers the
fastify-aws-snsplugin, making SNS functions available on the Fastify instance (e.g.,fastify.snsTopics,fastify.snsMessage). The plugin automatically picks up AWS credentials from environment variables (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION) if they follow the standard naming convention.// plugins/sns.js 'use strict'; const fp = require('fastify-plugin'); const fastifyAwsSns = require('fastify-aws-sns'); async function snsPlugin(fastify, options) { // The plugin automatically uses AWS credentials from environment variables // (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) // or IAM roles if running on EC2/ECS/Lambda. fastify.register(fastifyAwsSns); fastify.log.info('AWS SNS Plugin registered'); } module.exports = fp(snsPlugin, { name: 'snsPlugin', // Specify dependencies if needed, e.g., ['configPlugin'] });fastify-plugin? It prevents Fastify from creating separate encapsulated contexts for the plugin, ensuring that thefastify.sns*decorators are available globally across your application routes.2.2. Understanding Plugin Methods
The
fastify-aws-snsplugin exposes several methods categorized under namespaces attached to thefastifyinstance. These typically include:fastify.snsTopics: For managing topics (create, list, delete, get/set attributes).fastify.snsMessage: For publishing messages to a topic.fastify.snsSubscriptions: For managing subscriptions (list, subscribe different protocols like email/SMS/HTTP, confirm, unsubscribe).fastify.snsSMS: For SMS-specific actions (publish direct SMS, check opt-out status, manage SMS attributes).Important: The exact methods and their behavior depend on the plugin version. Always consult the
fastify-aws-snsdocumentation or source code to confirm available functionality and parameters. If the plugin lacks a specific feature (e.g., advanced pagination, batching), you may need to use the AWS SDK directly (see Section 9.1 - Note: Section 9.1 is mentioned but not provided in the original text).We will use these methods within our API routes in the next section, assuming they function as described.
3. Building the API Layer
We'll define RESTful endpoints to interact with SNS functionalities.
3.1. Basic Authentication Hook
For simplicity, we'll use a basic API key check. In production, use a more robust method like JWT or OAuth (see Section 7 - Note: Section 7 is mentioned but not provided in the original text).
Add this hook at the beginning of your main routes file (
routes/index.js).// routes/index.js (Start of file) 'use strict'; const API_KEY = process.env.API_KEY; // Basic Authentication Hook async function authenticate(request, reply) { const apiKey = request.headers['x-api-key']; if (!API_KEY || !apiKey || apiKey !== API_KEY) { reply.code(401).send({ error: 'Unauthorized', message: 'Valid API key required' }); return; // Stop execution } }3.2. Route Definitions (
routes/index.js)Define the routes within an async function exported by the module.
// routes/index.js (Continued) const { SNSClient, ListTopicsCommand, ListSubscriptionsByTopicCommand } = require(""@aws-sdk/client-sns""); // For direct SDK calls if needed async function routes(fastify, options) { // Apply the authentication hook to all routes defined in this plugin fastify.addHook('preHandler', authenticate); // Initialize SNS Client for direct SDK calls if needed // Credentials and region are picked up from environment by default const snsClient = new SNSClient({}); fastify.log.info('Registering API routes...'); // --- Topic Management --- // POST /api/topics - Create a new SNS topic fastify.post('/topics', { schema: { body: { type: 'object', required: ['topicName'], properties: { topicName: { type: 'string', minLength: 1, maxLength: 256, pattern: '^[a-zA-Z0-9_-]+$' }, isFifo: { type: 'boolean', default: false }, // Optional: For FIFO topics tags: { type: 'object' } // Optional: { key: value, ... } } }, response: { 201: { type: 'object', properties: { TopicArn: { type: 'string' } } } } } }, async (request, reply) => { const { topicName, isFifo, tags } = request.body; const attributes = {}; if (isFifo) { attributes.FifoTopic = 'true'; // FIFO topics require .fifo suffix if (!topicName.endsWith('.fifo')) { reply.code(400).send({ error: 'Bad Request', message: 'FIFO topic names must end with .fifo' }); return; } // ContentBasedDeduplication is often useful for FIFO attributes.ContentBasedDeduplication = 'true'; } const params = { topic: topicName, // Plugin uses 'topic' for name attributes: attributes, tags: tags ? Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) : undefined }; try { fastify.log.info(`Creating SNS topic: ${topicName}`); // Assuming fastify.snsTopics.create exists and works as expected const result = await fastify.snsTopics.create(params); reply.code(201).send({ TopicArn: result.TopicArn }); } catch (error) { fastify.log.error({ error, params }, 'Error creating SNS topic'); reply.code(500).send({ error: 'Failed to create topic', message: error.message }); } }); // GET /api/topics - List existing SNS topics fastify.get('/topics', { schema: { query: { // Add query schema for pagination token type: 'object', properties: { nextToken: { type: 'string' } } }, response: { 200: { type: 'object', properties: { Topics: { type: 'array', items: { type: 'object', properties: { TopicArn: { type: 'string' } } } }, NextToken: { type: ['string', 'null'] } // For pagination } } } } }, async (request, reply) => { const { nextToken } = request.query; try { fastify.log.info(`Listing SNS topics (nextToken: ${nextToken})`); // Use direct SDK for reliable pagination const command = new ListTopicsCommand({ NextToken: nextToken }); const result = await snsClient.send(command); reply.send({ Topics: result.Topics || [], NextToken: result.NextToken || null }); // --- Alternative using plugin (if pagination is not needed or verified to work): --- // const result = await fastify.snsTopics.list({}); // Check plugin docs for pagination support // reply.send(result); } catch (error) { fastify.log.error({ error }, 'Error listing SNS topics'); reply.code(500).send({ error: 'Failed to list topics', message: error.message }); } }); // --- Subscription Management --- // POST /api/topics/:topicArn/subscriptions - Subscribe an endpoint fastify.post('/topics/:topicArn/subscriptions', { schema: { params: { type: 'object', required: ['topicArn'], properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } } }, body: { type: 'object', required: ['protocol', 'endpoint'], properties: { protocol: { type: 'string', enum: ['sms', 'email', 'email-json'] }, endpoint: { type: 'string' } // Phone number (E.164) or email address } }, response: { // SNS subscription requires confirmation (except sometimes SMS depending on region/settings) 202: { type: 'object', properties: { message: { type: 'string' }, SubscriptionArn: { type: 'string' } // Often 'pending confirmation' } } } } }, async (request, reply) => { const { topicArn } = request.params; const { protocol, endpoint } = request.body; let subscribePromise; // Plugin parameter names might differ slightly from SDK - check plugin docs const params = { topicArn }; try { fastify.log.info(`Subscribing ${endpoint} (${protocol}) to topic ${topicArn}`); switch (protocol) { case 'sms': // Validate E.164 format (+ followed by country code and number) if (!/^\+[1-9]\d{1,14}$/.test(endpoint)) { reply.code(400).send({ error: 'Bad Request', message: 'Invalid phone number format. Use E.164 (e.g., +12125551234).' }); return; } params.phoneNumber = endpoint; // Assuming plugin uses 'phoneNumber' // Verify method name and parameters with plugin documentation subscribePromise = fastify.snsSubscriptions.setBySMS(params); break; case 'email': params.email = endpoint; // Assuming plugin uses 'email' // Verify method name and parameters with plugin documentation subscribePromise = fastify.snsSubscriptions.setByEMail(params); break; case 'email-json': params.email = endpoint; // Assuming plugin uses 'email' // Verify method name and parameters with plugin documentation subscribePromise = fastify.snsSubscriptions.setByEMailJSON(params); break; default: reply.code(400).send({ error: 'Bad Request', message: `Unsupported protocol: ${protocol}` }); return; } const result = await subscribePromise; // Note: Email subscriptions return a SubscriptionArn of 'pending confirmation' // and require confirmation via a link sent to the email. // SMS might auto-confirm in some regions/setups or also pend. reply.code(202).send({ message: `Subscription request sent for ${endpoint}. Confirmation might be required.`, SubscriptionArn: result.SubscriptionArn || 'pending confirmation' }); } catch (error) { fastify.log.error({ error, params }, 'Error subscribing endpoint'); reply.code(500).send({ error: 'Failed to subscribe', message: error.message }); } }); // GET /api/topics/:topicArn/subscriptions - List subscriptions for a topic fastify.get('/topics/:topicArn/subscriptions', { schema: { params: { type: 'object', required: ['topicArn'], properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } } }, query: { // Add query schema for pagination token type: 'object', properties: { nextToken: { type: 'string' } } }, response: { 200: { type: 'object', properties: { Subscriptions: { type: 'array', items: { type: 'object', properties: { SubscriptionArn: { type: 'string' }, Owner: { type: 'string' }, Protocol: { type: 'string' }, Endpoint: { type: 'string' }, TopicArn: { type: 'string' } } } }, NextToken: { type: ['string', 'null'] } } } } } }, async (request, reply) => { const { topicArn } = request.params; const { nextToken } = request.query; try { fastify.log.info(`Listing subscriptions for topic ${topicArn} (nextToken: ${nextToken})`); // Use direct SDK for reliable pagination const command = new ListSubscriptionsByTopicCommand({ TopicArn: topicArn, NextToken: nextToken }); const result = await snsClient.send(command); reply.send({ Subscriptions: result.Subscriptions || [], NextToken: result.NextToken || null }); // --- Alternative using plugin (if pagination is not needed or verified to work): --- // const result = await fastify.snsSubscriptions.list({ topicArn }); // Check plugin docs for pagination support // reply.send(result); } catch (error) { fastify.log.error({ error, topicArn }, 'Error listing subscriptions'); reply.code(500).send({ error: 'Failed to list subscriptions', message: error.message }); } }); // --- Message Publishing --- // POST /api/topics/:topicArn/publish - Publish a message to a topic fastify.post('/topics/:topicArn/publish', { schema: { params: { type: 'object', required: ['topicArn'], properties: { topicArn: { type: 'string', pattern: '^arn:aws:sns:.*:.*:.*$' } } }, body: { type: 'object', required: ['message'], properties: { message: { type: 'string', minLength: 1 }, subject: { type: 'string', maxLength: 100 }, // Primarily for email messageAttributes: { type: 'object' } // Optional: { key: { DataType: 'String', StringValue: 'value' }, ...} } }, response: { 200: { type: 'object', properties: { MessageId: { type: 'string' } } } } } }, async (request, reply) => { const { topicArn } = request.params; const { message, subject, messageAttributes } = request.body; // Plugin parameter names might differ slightly from SDK - check plugin docs const params = { topicArn, message, subject, // Subject is ignored by SMS messageAttributes }; try { fastify.log.info(`Publishing message to topic ${topicArn}`); // Assuming fastify.snsMessage.publish exists and works as expected const result = await fastify.snsMessage.publish(params); reply.send({ MessageId: result.MessageId }); } catch (error) { fastify.log.error({ error, params }, 'Error publishing message to topic'); reply.code(500).send({ error: 'Failed to publish message', message: error.message }); } }); // POST /api/sms/publish - Send a direct SMS message fastify.post('/sms/publish', { schema: { body: { type: 'object', required: ['phoneNumber', 'message'], properties: { phoneNumber: { type: 'string', pattern: '^\+[1-9]\d{1,14}$' }, // E.164 format message: { type: 'string', minLength: 1, maxLength: 1600 }, // Check SNS limits senderId: { type: 'string', maxLength: 11 }, // Optional: Custom Sender ID (if supported) messageType: { type: 'string', enum: ['Promotional', 'Transactional'], default: 'Promotional' } } }, response: { 200: { type: 'object', properties: { MessageId: { type: 'string' } } } } } }, async (request, reply) => { const { phoneNumber, message, senderId, messageType } = request.body; // Plugin parameter names might differ slightly from SDK - check plugin docs const params = { phoneNumber, message, messageAttributes: {} // Note: Verify if plugin handles attributes directly in publish or requires separate setAttributes call }; if (senderId) { params.messageAttributes['AWS.SNS.SMS.SenderID'] = { DataType: 'String', StringValue: senderId }; } params.messageAttributes['AWS.SNS.SMS.SMSType'] = { DataType: 'String', StringValue: messageType }; try { fastify.log.info(`Sending direct SMS to ${phoneNumber}`); // Assuming fastify.snsSMS.publish exists and handles messageAttributes correctly. // If not, you might need to call a separate setAttributes method via plugin or SDK first. const result = await fastify.snsSMS.publish(params); reply.send({ MessageId: result.MessageId }); } catch (error) { fastify.log.error({ error, params }, 'Error sending direct SMS'); reply.code(500).send({ error: 'Failed to send SMS', message: error.message }); } }); fastify.log.info('API routes registered.'); } module.exports = routes;async/await? Simplifies handling asynchronous operations like calling the AWS API.3.3. API Testing Examples (
curl)Replace placeholders like
YOUR_API_KEY,YOUR_TOPIC_ARN,+12223334444with actual values. Use single quotes around JSON data forcurlon most shells.Create Topic:
curl -X POST http://localhost:3000/api/topics \ -H ""Content-Type: application/json"" \ -H ""x-api-key: YOUR_API_KEY"" \ -d '{ ""topicName"": ""my-new-campaign"" }' # Expected: 201 Created with { ""TopicArn"": ""arn:aws:sns:..."" }List Topics:
curl http://localhost:3000/api/topics -H ""x-api-key: YOUR_API_KEY"" # Expected: 200 OK with { ""Topics"": [...] }Subscribe SMS:
curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/subscriptions \ -H ""Content-Type: application/json"" \ -H ""x-api-key: YOUR_API_KEY"" \ -d '{ ""protocol"": ""sms"", ""endpoint"": ""+12223334444"" }' # Expected: 202 Accepted with { ""message"": ""..."", ""SubscriptionArn"": ""..."" }Subscribe Email:
curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/subscriptions \ -H ""Content-Type: application/json"" \ -H ""x-api-key: YOUR_API_KEY"" \ -d '{ ""protocol"": ""email"", ""endpoint"": ""user@example.com"" }' # Expected: 202 Accepted with { ""message"": ""..."", ""SubscriptionArn"": ""pending confirmation"" } # Check user@example.com for a confirmation email.Publish to Topic:
curl -X POST http://localhost:3000/api/topics/YOUR_TOPIC_ARN/publish \ -H ""Content-Type: application/json"" \ -H ""x-api-key: YOUR_API_KEY"" \ -d '{ ""message"": ""Hello subscribers!"", ""subject"": ""Campaign Update"" }' # Expected: 200 OK with { ""MessageId"": ""..."" } # Check subscribed endpoints (SMS/Email)Publish Direct SMS:
curl -X POST http://localhost:3000/api/sms/publish \ -H ""Content-Type: application/json"" \ -H ""x-api-key: YOUR_API_KEY"" \ -d '{ ""phoneNumber"": ""+12223334444"", ""message"": ""Direct message test"" }' # Expected: 200 OK with { ""MessageId"": ""..."" } # Check the phone number4. Integrating with AWS SNS (Credentials & IAM)
Securely connecting to AWS is paramount.
4.1. Create an IAM User
It's best practice to create a dedicated IAM user with least privilege access.
fastify-sns-api-user).AmazonSNSFullAccesspolicy. Note: For stricter security, create a custom policy granting only the specific SNS actions needed (e.g.,sns:CreateTopic,sns:ListTopics,sns:Subscribe,sns:Publish,sns:ListSubscriptionsByTopic,sns:SetSMSAttributes,sns:CheckIfPhoneNumberIsOptedOut,sns:GetSMSSandboxAccountStatusetc.). Start withAmazonSNSFullAccessfor simplicity during development, but refine for production..envfile locally, or a secrets manager in production). You won't be able to see the secret key again.4.2. Configure Credentials
Store the copied credentials securely in your
.envfile for local development:AWS_ACCESS_KEY_ID: Identifies the IAM user.AWS_SECRET_ACCESS_KEY: The secret password for the user. Treat this like a password.AWS_REGION: The AWS region where your SNS topics and resources will reside (e.g.,us-east-1,eu-west-1). SNS is region-specific.The
fastify-aws-snsplugin (and the underlying AWS SDK) will automatically detect and use these environment variables. If deploying to AWS services like EC2, ECS, or Lambda, you should use IAM Roles instead, which is more secure as credentials aren't hardcoded or stored in files.5. Error Handling, Logging, and Retry Mechanisms
Robust applications need to handle failures gracefully.
5.1. Consistent Error Handling
Fastify allows defining a global error handler. We'll augment the existing logging within routes.
Add this to
server.jsbeforestart():// server.js (before start()) fastify.setErrorHandler(function (error, request, reply) { // Log the error fastify.log.error({ request: { method: request.method, url: request.url, headers: request.headers, // Avoid logging sensitive data from body in production body: process.env.NODE_ENV !== 'production' ? request.body : undefined, query: request.query, params: request.params, }, error: { message: error.message, stack: error.stack, code: error.code, // AWS SDK errors often have codes statusCode: error.statusCode, }, }, 'Unhandled error occurred'); // Send generic error response in production if (process.env.NODE_ENV === 'production' && (!error.statusCode || error.statusCode >= 500)) { reply.status(500).send({ error: 'Internal Server Error', message: 'An unexpected error occurred' }); return; } // Send detailed error response in development or for client errors (4xx) const statusCode = error.statusCode >= 400 ? error.statusCode : 500; reply.status(statusCode).send({ error: error.name || 'Internal Server Error', message: error.message || 'An unexpected error occurred', code: error.code, // Include code for debugging }); });setErrorHandler? Catches unhandled errors thrown within route handlers or plugins, ensuring a consistent error response format and logging.InvalidParameterValue,AuthorizationError,ThrottlingException) which are useful for diagnostics.5.2. Logging
Fastify's built-in Pino logger (
logger: true) is efficient.infoand above. You can configure the level:logger: { level: 'debug' }.fastify.log.info,fastify.log.error) to show application-specific actions and errors.5.3. Retry Mechanisms
SNS Delivery Retries: AWS SNS automatically handles retries for delivering messages to subscribed endpoints (e.g., if an email server is temporarily down or an SMS gateway is busy). This is configured within SNS itself (Delivery policy).
API Call Retries: What if the API call to AWS SNS fails due to network issues or transient AWS problems (like throttling)?
fastify.sns*.publish()or direct SDK call in a simple retry loop with exponential backoff.async-retryfor more sophisticated retry logic.Example using
async-retry(installnpm i async-retry):// Example within a route handler (e.g., publish) const retry = require('async-retry'); // ... inside POST /api/topics/:topicArn/publish handler ... try { const result = await retry(async (bail, attempt) => { fastify.log.info(`Publishing message to topic ${topicArn}, attempt ${attempt}`); try { // Use either the plugin or direct SDK call here return await fastify.snsMessage.publish(params); // OR: return await snsClient.send(new PublishCommand(sdkParams)); } catch (error) { // Don't retry on client errors (4xx) like invalid parameters // AWS SDK v3 errors might not have statusCode directly, check $metadata const statusCode = error.$metadata?.httpStatusCode || error.statusCode; if (statusCode >= 400 && statusCode < 500) { // Give up on client errors bail(new Error(`Non-retriable error (${statusCode}): ${error.message}`)); return; // Important: return after bail } // Throw other errors (like 5xx, network errors) to trigger retry throw error; } }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay ms onRetry: (error, attempt) => { fastify.log.warn(`Retrying publish operation (attempt ${attempt}) due to error: ${error.message}`); } }); reply.send({ MessageId: result.MessageId }); } catch (error) { // This catches errors after retries have failed or non-retriable errors fastify.log.error({ error, params }, 'Error publishing message to topic after retries'); // Use the status code from the bailed error if available const finalStatusCode = error.originalError?.$metadata?.httpStatusCode || error.originalError?.statusCode || 500; reply.code(finalStatusCode).send({ error: 'Failed to publish message', message: error.message }); } // ... rest of handler ...