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.)
System Architecture:
+-----------+ +-----------------+ +-----------+ +---------------------+
| Client | ----> | Fastify API | ---- | AWS SNS | ---- | User Endpoints |
| (Web/App) | | (Node.js/Docker)| | (Topics) | | (SMS, Email, etc.) |
+-----------+ +-----------------+ +-----------+ +---------------------+
| ^
| | (Optional: Store metadata)
v |
+-----------+
| Database |
| (Postgres)|
+-----------+
Expected Outcome:
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:
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).
npm install fastify fastify-aws-sns dotenv @aws-sdk/client-sns
fastify
: The core web framework.fastify-aws-sns
: The plugin for easy SNS integration.dotenv
: Loads environment variables from a.env
file 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:
fastify-sns-campaigns/
├── node_modules/
├── routes/
│ └── index.js # Main route definitions
├── plugins/
│ └── sns.js # Plugin registration for SNS
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore
├── server.js # Main application entry point
└── package.json
.gitignore
1.4. Configure Create a .gitignore
file to prevent committing sensitive information and unnecessary files:
# .gitignore
node_modules
.env
npm-debug.log
*.log
.env
)
1.5. Environment Variables (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
# 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.
server.js
)
1.6. Basic Server Setup (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();
- 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.
plugins/sns.js
)
2.1. Register the SNS Plugin (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.
// 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']
});
- Why
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-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
).
// 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
}
}
routes/index.js
)
3.2. Route Definitions (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;
- 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.
curl
)
3.3. API Testing Examples (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:
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 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 withAmazonSNSFullAccess
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
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()
:
// 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
});
});
- 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
(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 ...
- Simple Approach: For critical operations like publishing, you could wrap the