Starter Guides

Implementing a Global Rate Limiter with AWS and TypeScript: A Step-by-Step Guide

Implementing a Global Rate Limiter with AWS and TypeScript: A Step-by-Step Guide

Implementing a Global Rate Limiter with AWS and TypeScript: A Step-by-Step Guide

Saurabh Jain

Sep 13, 2024

In a world where APIs are at the core of most applications, ensuring that services don’t get overwhelmed by excessive traffic is crucial. Global rate limiting is one of the most effective ways to regulate traffic, especially when multiple users are accessing your API simultaneously. In this article, we will walk through how to implement a global rate limiter in TypeScript using AWS infrastructure. We’ll also explore how to avoid potential issues like race conditions and highlight best practices for handling concurrency.

Why Global Rate Limiting?

Global rate limiting ensures that your API is not overwhelmed by too many requests across all users, unlike local rate limiting that works at a per-user or per-IP level. This type of rate limiting is especially useful for public APIs or shared services, where overall traffic needs to be managed without differentiating between individual users.

Here are the benefits of a global rate limit service:

  • Ensures fair usage across all clients.

  • Prevents API abuse and DoS attacks.

  • Helps maintain consistent performance of upstream services.

Infrastructure Overview

To implement global rate limiting, we’ll leverage the following AWS services:

  1. AWS DynamoDB: To store request counts per client or globally.

  2. AWS Lambda: To manage the rate limiting logic.

  3. Amazon API Gateway: To route requests to your Lambda function.

  4. Amazon ElastiCache (Redis): For high-performance request tracking.

Step 1: Set Up a DynamoDB Table

We’ll start by creating a DynamoDB table to track the number of requests for each API key. This table will store data related to the request rate of each client or globally.

aws dynamodb create-table \
    --table-name GlobalRateLimitTable \
    --attribute-definitions AttributeName=APIKey,AttributeType=S \
    --key-schema AttributeName=APIKey,KeyType=HASH \
    --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

In this table, the APIKey is the partition key. We can use this to either set global limits or individual rate limits based on the client IP address or a unique API key.

Step 2: Implement the Global Rate Limiter Logic in TypeScript

The rate limiting logic will be implemented in an AWS Lambda function. This function will enforce the rate limiting rules by checking the number of requests made by each client and deciding whether to allow or block them.

import { DynamoDB } from 'aws-sdk';

const dynamoDb = new DynamoDB.DocumentClient();
const tableName = process.env.DYNAMODB_TABLE;
const RATE_LIMIT = 100;
const TIME_WINDOW = 60 * 60 * 1000; // 1 hour

export const handler = async (event: any) => {
  const apiKey = event.headers['x-api-key'] || 'global'; 
  const currentTime = Date.now();

  // Retrieve the request count and last reset time
  const result = await dynamoDb.get({
    TableName: tableName,
    Key: { APIKey: apiKey }
  }).promise();

  let requestCount = 0;
  let timeFrameStart = currentTime;

  if (result.Item) {
    requestCount = result.Item.requestCount;
    timeFrameStart = result.Item.timeFrameStart;
  }

  // Reset the count if the time window has passed
  if (currentTime - timeFrameStart >= TIME_WINDOW) {
    requestCount = 0;
    timeFrameStart = currentTime;
  }

  if (requestCount >= RATE_LIMIT) {
    return {
      statusCode: 429,
      body: JSON.stringify({ message: "Rate limit exceeded" }),
    };
  }

  // Increment the request count
  requestCount++;
  await dynamoDb.put({
    TableName: tableName,
    Item: { APIKey: apiKey, requestCount, timeFrameStart },
  }).promise();

  return { statusCode: 200, body: JSON.stringify({ message: "Request successful" }) };
};

Step 3: Dealing with Race Conditions

Handling concurrent requests is tricky, especially when multiple requests might try to update the same rate limit counter simultaneously, leading to race conditions. To avoid this, we can use various techniques.

Optimistic Locking with DynamoDB

Optimistic locking ensures that we only update the request count if no other request has done so at the same time.

try {
  await dynamoDb.update({
    TableName: tableName,
    Key: { APIKey: apiKey },
    UpdateExpression: 'SET requestCount = requestCount + :inc',
    ConditionExpression: 'requestCount < :limit',
    ExpressionAttributeValues: {
      ':inc': 1,
      ':limit': RATE_LIMIT
    }
  }).promise();
} catch (err) {
  if (err.code === 'ConditionalCheckFailedException') {
    return { statusCode: 429, body: JSON.stringify({ message: "Rate limit exceeded" }) };
  }
  throw err;
}

Using Redis for Atomic Operations

You can also implement rate limiting using Redis, which supports atomic operations like INCR that avoid race conditions. Redis-based rate limiting is especially useful for high-throughput APIs because of its performance.

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const RATE_LIMIT = 100;
const TIME_WINDOW = 3600; // 1 hour in seconds

export const handler = async (event: any) => {
  const apiKey = event.headers['x-api-key'] || 'global';

  // Increment request count atomically
  const requestCount = await redis.incr(apiKey);

  if (requestCount === 1) {
    await redis.expire(apiKey, TIME_WINDOW);
  }

  if (requestCount > RATE_LIMIT) {
    return { statusCode: 429, body: JSON.stringify({ message: "Rate limit exceeded" }) };
  }

  return { statusCode: 200, body: JSON.stringify({ message: "Request successful" }) };
};

Step 4: Deploy Your Lambda Function and API Gateway

You can deploy your Lambda function and API Gateway using tools like Serverless Framework or AWS SAM. Be sure to configure your API Gateway to forward requests to your Lambda function and include necessary environment variables like DynamoDB table names and Redis URLs.

serverless deploy

Monitoring and Scaling

Use CloudWatch and Prometheus metrics to monitor your API's request rate and the rate-limited requests. These tools help you track the efficiency of your rate limiting service and identify bottlenecks.

You can scale your rate limiter by increasing the capacity of your DynamoDB table or Redis cache, depending on the load. Ensure that upstream services can handle the reduced traffic once the rate limit kicks in.

Conclusion

A global rate limiter ensures that your API remains responsive and prevents abuse. By using AWS services like DynamoDB, Lambda, and Redis, you can create a robust and scalable rate limiting solution in TypeScript. Additionally, addressing concurrency through optimistic locking or atomic Redis commands will help avoid race conditions and keep your rate limits accurate.

Implementing a global rate limit service is vital for managing API traffic at scale, ensuring fair access, and protecting your backend from overload.

If you are looking to add rate limiting to your APIs, reach out to me on saurabh@oneloop.ai, or book a call through our website.

Saurabh Jain

Share this post