Sending emails by SES as fast as possible without exceeding the rate limit

Viewed 3599

I'm currently using SendGrid to send emails from many serverless node.js Lambda functions, and planning to transfer from SendGrid to SES (because of the reasonable price).

SendGrid's rate limit is so high that sending emails to users using for-loops by some lambda functions concurrently never caused a problem. But SES has lower rate limit (50 emails per second in my case), and maximum sending rate limit exceeded error occurs.

How is it possible to send emails as fast as possible without exceeding the rate limit?

My current plan is to send email request to SQS queue by many lambda functions, and receive SQS message to send emails by 1 Lambda function running without stopping time. But I'm not sure how to control the speed of sending requests to SES.

5 Answers

My conclusion

I decided to create two SQS queues (A and B). Queue B is for triggering an email sending function, and queue A is for adding messages in the waiting queue. So I only need to control the speed of moving messages from queue A to B, and that makes everything simple.

enter image description here

I considered the following things.

Error handling

At first, I was thinking of using while-loops to send many emails by a single Lambda function, but that makes error handling difficult. When the rate limit exceeded error or some other unexpected exception occurs, it's necessary to wait for a while and retry, but it could exceed the timeout of Lambda function.

Scalability

Receiving 50 messages from SQS queue and sending 50 requests to SES in 1 second by a single Lambda function could be possible, but if the rate limit goes up to 300 or 500, it might be impossible to handle that many requests in a second. Moving messages from one SQS queue to another, in order to invoke a Lambda function per email, is relatively easy to scale.

Email priority

Signup confirmation email with a verification code needs to be sent instantly, but campaign emails can be sent on a delay. With two SQS queues, I can control the priority.

AWS cost

Invoking a Lambda function for each email takes more time than sending many emails in a single Lambda function, but the AWS cost for that is not very expensive. If it takes 1 second to send a request to SES, it costs 0.000002083 USD for Lambda execution with 128 MB memory (N.Virginia region). That's only 0.02083 USD for 1000 emails and can be ignored.

You could also use Lambda Reserved Concurrency feature to limit the number of concurrent Lambda executions to 50. And set the batch size for SQS to 1 message.

In serverless framework, this will look like:

  sendNotificationsEmails:
    handler: lib/send-email/handler.handler
    reservedConcurrency: 50
    events:
      - sqs:
          arn: xxxxx
          batchSize: 1

This will ensure you won't have >50 Lambda instances running at the same time.

But then a new problem arises - if your function executes too fast in <1s, you'll end up exceeding the rate limit. You have 2 options now:

  • Retry requests until they succeed
  • Reduce reservedConcurrency proportionally

If you decide to reduce reservedConcurrency, let's say your code runs in 500ms. This means, if you have 50 concurrent Lambda instances, they will send 50 emails in the first 0.5s, then attempt to send 50 more emails in the 2nd 0.5s.

Then set reservedConcurrency to 25. But I still recommend having retry code around aws-sd

The following approach guarantees that you'll have x concurrent calls while your arrayWithAllTheEmails contains x or more elements.

Every time one call ends it calls the next one.

let ongoing = 0

function sendXEmailsAtATime (arrayWithAllTheEmails, max = 50) {
  while (ongoing < max) {
    ongoing++
    const params = prepareParams(arrayWithAllTheEmails.shift())

    ses.sendEmail(params, (err, data) => {
      if (err) console.log(err, err.stack); // an error occurred
      else     console.log(data);           // successful response

      ongoing--
      if (arrayWithAllTheEmails.length > 0) {
        sendXEmailsAtATime(arrayWithAllTheEmails, max)
      }
    });
  }
}

function prepareParams (oneItemFromTheArrayWithAllEmails) {
  // ... here you prepare the params and return it
}

Solution 1

I think you create a scheduled event using CloudWatch and trigger your event every 1 second or 2 seconds and it will call the lambda function. Inside lambda function only retrieve process 50 messages from SQS and ends the lambda function.

Solution 2

But I have seen you have put this running without stopping time.

If you want to do so then you will have to put a time counter from the start of sending the email requests to SES to the moment 50 requests are sent.

If the time difference is greater than 1 second then process the next set of emails.

If the time difference is less than 1 second then wait for the remaining time and then process the next set of emails.

The SES documentation is discussing the same issue at this link and its suggesting the introduction of rate limit logic in your code.

https://aws.amazon.com/blogs/messaging-and-targeting/how-to-handle-a-throttling-maximum-sending-rate-exceeded-error/

As you are using node so you can have a look at this rate limiter library https://www.npmjs.com/package/ratelimiter

You can achieve this by using a sleep().

 async function init() {
      console.log(1);
      await sleep(10000);
    }
Related