A Node.js + Serverless + S3 + SQS + DLQ + PostgreSQL proof of concept.
$ make up
$ make dev
$ make remq
$ make down
$ npm install serverless # having it as global package is discouraged
# pnpm can be used as an alternative to enforce the exclusion of serverless in global scope
$ npx sls
$ npx sls invoke local -f path/to/function
$ npx sls plugin install -n serverless-offline serverless-dotenv-plugin # ... append other plugins
# automatically added in package.json
$ npx sls offline start
$ nodemon --ignore ./s3-local/* --exec sls offline start # already set as npm run dev
# refer to "Solutions to errors" section to see why `start` is required
# useDotenv must be set as true
$ npx sls offline start # defaults to "development"
$ npx sls offline start --stage production # matches with .env.production
# .env, .env.development, and .env.production files should be included in out repository as they define defaults.
# .env.*.local files should be added to .gitignore, as those files are intended to be ignored. .env.local is where secrets can be stored.
# DOTENV: Loading environment variables from .env, .env.development, .env.development.local
# serverless-dotenv-plugin loads by priority: .env > .env.development > .env.development.local
# each of repeating variables are not overwritten by the next .env file
// Problematic code
module.exports.handler = (event, context, callback) => {
console.log(JSON.stringify(event));
console.log(JSON.stringify(context));
console.log(JSON.stringify(process.env));
};
// Solution
module.exports.handler = (event, context, callback) => {
console.log(JSON.stringify(event));
console.log(JSON.stringify(context));
console.log(JSON.stringify(process.env));
callback(null, "ok");
};
// Problematic code
module.exports.handler = (event, context, callback) => {
//...
};
// Solution
module.exports.handler = async (event, context, callback) => {
//...
};
Server responding 502 Bad Gateway
on every single request (although error handling logic seems to be correct)
Missing try-catch block.
// Problematic code
module.exports.handler = async (event, _, callback) => {
const { body } = JSON.parse(event.body);
if (!body) {
throw new Error("Body can't be empty");
}
// further logic
};
// Solution
module.exports.handler = async (event, _, callback) => {
const { body } = JSON.parse(event.body);
try {
if (!body) {
throw new Error("Body can't be empty");
}
// further logic
} catch (err) {
return {
// response body with error message
};
}
};
Missing async/await
pairs.
// Problematic code
module.exports.handler = async (/*...*/) => {
try {
anAsyncFunction(/*...*/).then(/*...*/).catch(/*...*/);
} catch (err) {
return {
// response body with error message
};
}
};
const anAsyncFunction = (/*...*/) => {
//...
};
// Solution
module.exports.handler = async (/*...*/) => {
try {
await anAsyncFunction(/*...*/).then(/*...*/).catch(/*...*/);
} catch (err) {
return {
// response body with error message
};
}
};
const anAsyncFunction = async (/*...*/) => {
//...
};
This can be proofed by the following code:
const { GetObjectCommand, NoSuchBucket } = require("@aws-sdk/client-s3");
const { s3Client } = require("./s3Client");
module.exports.handler = async (/*...*/) => {
try {
const getObjectCommandOutput = await s3Client.send(
new GetObjectCommand({
/*...*/
}),
);
} catch (error) {
if (error instanceof NoSuchBucket) {
return {
/*...*/
};
}
// reached
throw new Error("Unidentified error");
}
};
We expect an error to be caught by the second if
statement, but it's not.
Both commands: ListObjectsV2Command
& GetObjectCommand
return an exception with the same data, which is observable console.log
ging both error values. Both commands seems to return NoSuchBucket
if the bucket does not exists. However, they differ as we evaluate them with the instanceof
operator. Instead, we should use S3ServiceException
:
const { GetObjectCommand, S3ServiceException } = require("@aws-sdk/client-s3");
const { s3Client } = require("./s3Client");
module.exports.handler = async (/*...*/) => {
try {
const getObjectCommandOutput = await s3Client.send(
new GetObjectCommand({
/*...*/
}),
);
} catch (error) {
if (error instanceof S3ServiceException) {
return {
/*...*/
};
}
// never reached
throw new Error("Unidentified error");
}
};
A possible alternative is to use error.Code
, which unifies the error handling logic:
const { GetObjectCommand } = require("@aws-sdk/client-s3");
const { s3Client } = require("./s3Client");
module.exports.handler = async (/*...*/) => {
try {
const getObjectCommandOutput = await s3Client.send(
new GetObjectCommand({
/*...*/
}),
);
} catch (error) {
if (error.Code === "NoSuchBucket") {
return {
/*...*/
};
}
// never reached
throw new Error("Unidentified error");
}
};
Warning: AggregateError
at internalConnectMultiple (node:net:1114:18)
at afterConnectMultiple (node:net:1667:5)
Error:
AggregateError
at internalConnectMultiple (node:net:1114:18)
at afterConnectMultiple (node:net:1667:5) {
code: 'NetworkingError',
message: null,
region: 'us-east-1',
hostname: 'localhost',
retryable: true,
time: 2023-11-17T18:19:20.373Z,
[errors]: [
Error: connect ECONNREFUSED ::1:9324
at createConnectionError (node:net:1634:14)
at afterConnectMultiple (node:net:1664:40) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '::1',
port: 9324
},
Error: connect ECONNREFUSED 127.0.0.1:9324
at createConnectionError (node:net:1634:14)
at afterConnectMultiple (node:net:1664:40) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 9324
}
]
}
Instead of declaring the AWS access key and secret in the serverless.yml, we use the default AWS profile for development and deployment to hide our keys. Use ElasticMQ, an in-memory message queue system, with serverless-offline-sqs plugin to simulate the local AWS SQS environment. This can be done docker-composing the ElasticMQ service.
// Problematic code
import { SQSClient } from "@aws-sdk/client-sqs";
export default new SQSClient({
forcePathStyle: true,
});
// Solution
import { SQSClient } from "@aws-sdk/client-sqs";
export default new SQSClient({
endpoint: "us-east-1", // add a region
forcePathStyle: true,
});
make
may fail with the following error:
$ make
sudo service docker start
* Starting Docker: docker
make: *** [makefile:6: start] Error 1
Fix this by stopping Docker Desktop and running make stop
& make
again.
# Problematic command
$ sls offline # certain services are not triggered when using the command without `start`
# Solution
$ sls offline start
$ docker compose --env-file .env.development.local up -d
$ docker compose --env-file .env.development.local config # see resolved env variables
$ docker exec -ti containerId bash
$ psql -U root serverless-node-api-poc
$ psql \l
$ \q
$ docker ps --format 'table {{.Names}}\t{{.Ports}}\t{{.Status}}'