by Arpit Kumar
26 Mar, 2024
10 minute read
Building a Rate Limiter or RPM based Throttler for your API/worker

Building a simple rate limiter / throttler based on GCRA algorithm and redis script


When it comes to managing the flow of events or API requests, setting up a rate limiting or throttling system is crucial. A rate limiting or throttling system serves as a crucial control mechanism. It works as a safety harness for your workloads. It helps keep your system running smoothly. There are multiple ways in which your Rate limiter/Throttler can help regulate workloads. Some of them mentioned below to help us understand the importance of building a layer of these systems from the first day itself.

  • Protecting Resources: Every system has its limits. If left unchecked, a flood of data can overwhelm it, causing crashes, slowdowns, and overall poor performance. Rate limiting and throttling step in as safeguards, ensuring that resource usage stays within manageable bounds.
  • Ensuring Fairness: Without limits in place, some users or applications could monopolize the system’s resources with excessive requests, leaving others in the dust. Rate limiting levels the playing field, ensuring fair access for everyone.
  • Preventing Abuse: There are always those who might try to exploit systems for malicious purposes, launching attacks that flood the system and disrupt operations. Rate limiting is a key defense, identifying and blocking such attacks before they can cause harm.
  • Managing Data Flow: Certain tasks, like handling large volumes of data through APIs, require careful management. Throttling enables controlled data delivery, preventing sudden spikes that could overwhelm downstream systems or exceed bandwidth limits.
  • Cost Optimization: In the world of cloud services, unchecked resource usage can quickly lead to ballooning costs. Rate limiting keeps spending in check by preventing excessive resource consumption.
  • Improving User Experience: By avoiding overload situations, rate limiting and throttling contribute to a smoother user experience. Users won’t have to deal with sluggish performance or sudden system outages caused by too much traffic.
  • Security Measures: Rate limiting isn’t just about performance—it’s also a valuable security tool. By monitoring traffic patterns, it can help detect and mitigate potential attacks or unauthorized access attempts.

There are various approaches to implementing a rate limiting system. In this post, we will explore a simplified rate limiting system utilizing Redis and the Generic Cell Rate Algorithm (GCRA). It’s recommended to review other algorithms as well to gain a broader understanding of potential alternatives. Depending on your specific workload, a different algorithm might be more suitable. You should check out the list at the bottom for alternative algorithms.

Rate Limiting/Throttling using Generic Cell Rate Algorithm

Personally, I favor Generic Cell Rate Algorithm (GCRA) due to its qualities of low memory consumption, comprehensibility, and fairness. GCRA, or Generic Cell Rate Algorithm, operates much like a leaky bucket

  • The Bucket: Picture a bucket with a set size, symbolizing the permitted data rate (e.g., 100 requests per second).
  • The Leak: At the bottom of the bucket, there’s a hole allowing water (data) to trickle out at a steady pace (e.g., 1 request every 10 milliseconds).
  • Incoming Traffic: Data packets (cells) enter akin to water being poured into the bucket.

How GCRA functions in practice

  • Cells Within Limits: If incoming cells arrive at a rate slower than the leak, they flow through seamlessly.

  • Cell Bursts: GCRA accommodates some “burstiness.” The bucket can temporarily store extra data beyond its capacity, facilitating brief bursts of traffic exceeding the average rate.

  • Throttling Activation: Once cells start arriving faster than the leak can handle, the bucket overflows. This is where throttling intervenes. Excessive cells are either:

    • Delayed: Held back until space becomes available in the bucket.
    • Dropped: Discarded if the queue becomes too lengthy.

Dropping the request is easier. In case the bucket is filled then we can drop the request and let the space in the bucket get filled. This can be simulated in an alternative way. Let’s assume each request passes through our rate limiter system.

Learn using an easy example

The foundation of this implementation revolves around determining the optimal interval between two requests, guided by the system’s permissible Requests Per Second (RPS).

So for example if a system is allowed to process at 60 RPM it means that each request should have a gap of 1s. This rate limiting system basically figures out based on a previous request what is the theoretical time of arrival. If this is the first request then it would return the wait time to be zero and set the TAT to be (current time + 1000/rps). If it already has a TAT stored on the redis server it would check whether current time has passed the TAT or not. If TAT is more than server time then it would return the diff to be TAT - server time as the wait time for the request.

Based on the wait time, the client can decide if they want to drop or wait for the returned time to process the request. While in case of delaying the execution, the implementation can be done in two ways. First, the client can retry after the prescribed interval and second, the client can wait till that time and complete the processing without again making a call to rate limiter service.

Show me the code

Let’s implement two versions of the algorithm. We will implement the version where we will only calculate the Theoretical Arrival Time (TAT) and return the time to wait for before retry. We will use Redis Lua scripting to bring transactional behavior for calculation for TAT.

Implementation 1

In this implementation we are returning the wait time for this request till it can be processed and we are updating the TAT. This implementation assumes requests would not be retried but will be processed once the wait time is over.

    
class DynamicThrottleCache(object):

    def __init__(self):
        self.redis_connection = RedisConnectionFactory().get_connection()
        self.script = """
            local key = KEYS[1]
            local server_time = redis.call('TIME')
            local arrival_time = tonumber((server_time[1] * 1000000) + (server_time[2]))
            local increment = tonumber(ARGV[1])

            local stored_tat = redis.call('GET', key) -- Get the current value for the key
            local updated_tat -- Declare updated_tat as local variable

            -- If the key doesn't exist or is nil, set the TAT to current time + increment and return 0.
            -- If the key exists, then get the TAT in number format.
            if stored_tat == false or stored_tat == nil then
                updated_tat = arrival_time + increment
                redis.call('SET', key, updated_tat) -- Update the value for the key
                return 0
            else
                stored_tat = tonumber(stored_tat)
            end

            if arrival_time <= stored_tat then
                updated_tat = stored_tat + increment
                redis.call('SET', key, updated_tat) -- Update the value for the key
                return stored_tat - arrival_time
            else
                -- in case stored_tat is older than 60 secs then reset the tat by arrival time + increment
                -- this is done to avoid any bucket filling which is older than a min, buckets older than a min are discarded
                if arrival_time - stored_tat >= 60000000 then
                    updated_tat = arrival_time + increment
                else
                    updated_tat = stored_tat + increment
                end
                redis.call('SET', key, updated_tat) -- Update the value for the key
                return 0
            end
            """

        -- Check if the script is already loaded in redis, if not load it, else get the sha
        -- of the loaded script
        script_sha = self.redis_connection.script_exists(self.script)
        if not script_sha[0]:
            -- If the script is not loaded in redis, load it and get the sha of the loaded script
            self.script_sha = self.redis_connection.script_load(self.script)
        else:
            -- If the script is already loaded, get the sha of the loaded script
            self.script_sha = script_sha[1][0]

    def get_waittime_in_millis(self, key, rpm):
        wait_time_in_millis = self.get_waittime_in_micros(key, rpm) / 1000
        return max(wait_time_in_millis, 0)

    def get_waittime_in_micros(self, key, rpm):
        increment = 1000 / (rpm / 60) # in seconds
        try:
            wait_time = self.redis_connection.evalsha(self.script_sha, 1, key, increment)
        except Exception as e:
            if 'NOSCRIPT' in str(e):
                self.script_sha = self.redis_connection.script_load(self.script)
                wait_time = self.redis_connection.evalsha(self.script_sha, 1, key, increment)
            else:
                wait_time = 60.0 * 1_000_000 / rpm  # Default wait time in case of failure
                return wait_time
        wait_time_with_jitter = wait_time * random.uniform(0.9, 1.1)
        return max(wait_time_with_jitter, 0)

Implementation 2

Another implementation can be done where a request needs to be retried post the wait time and checked if it is allowed now. I would leave the implementation to the reader. This can be easily done by not incrementing the TAT on each request but only on the request which has arrived post the stored TAT.

These should help figure out a way to implement a decent rate limiter/throttler for processing requests. Since request processing is delayed, some way to handle the delay and process/retry at the end wait time is required. Either the thread can wait or it can be scheduled on some external trigger to be retried at a later time. In case thread is put to sleep it should be done in an async or non-blocking way so that cpu can be utilized by other threads in the process.

List of alternative Algorithms which are generally used for similar use cases

Token Bucket Algorithm

Maintains a fixed-size bucket of tokens that are replenished at a constant rate. Each request consumes a token, and requests are rejected if the bucket is empty. Simple to implement and provides a smooth rate-limiting experience.

Fixed Window Counter Algorithm

Counts the number of requests received in a fixed time window. If the count exceeds a specified threshold, requests are rejected. Provides precise rate limiting but can be sensitive to bursts of traffic.

Leaky Bucket Algorithm

Similar to the token bucket algorithm but with a leaky bucket that allows a constant outflow of tokens. Requests are rejected if the bucket is full when a request arrives. Offers a smoother rate-limiting experience than the fixed window counter algorithm.

Sliding Window Algorithm

Maintains a sliding window of time and counts the number of requests received in that window. Requests are rejected if the count exceeds a specified threshold. Provides a flexible and adaptable rate-limiting mechanism.

Hierarchical Rate Limiting

A multi-level rate-limiting approach where different rate limits are applied to different levels of the system. Enables fine-grained control over rate limiting and can be used to prioritize certain types of requests.

Recent Posts

Understanding Asynchronous I/O in Linux - io_uring
Explore the evolution of I/O multiplexing from `select(2)` to `epoll(7)`, culminating in the advanced io_uring framework
MicroVMs, Isolates, Wasm, gVisor: A New Era of Virtualization
Exploring the evolution and nuances of serverless architectures, focusing on the emergence of MicroVMs as a solution for isolation, security, and agility. We will discuss the differences between containers and MicroVMs, their use cases in serverless setups, and highlights notable MicroVM implementations by various companies. Focusing on FirecrackerVM, V8 isolates, wasmruntime and gVisor.
Sum of Bytes Tech Stack
Details about this blog tech stack and how I migrated from Ghost on Hetzner to Phoenix based markdown blog with bunch of technologies (Phoenix + Nimble Publisher + Neon Tech + Hetzner + Kamal Deploy + wsrv.nl) connected to provide me great experience

Get the "Sum of bytes" newsletter in your inbox
No spam. Just the interesting tech which makes scale possible.