Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 34

Decorator-Based Thread-Safe Rate Limiter

Recommended resources

Introduction

In our previous lessons, we designed a robust background job processor, complete with retry logic and a Dead-Letter Queue to handle persistent failures. We've focused on making our services resilient from within. Now, we'll shift our focus to protecting our services from the outside world. Any public-facing API is a potential target for abuse, whether intentional (malicious attacks) or unintentional (a buggy client script). Unchecked, this can overwhelm your servers and degrade performance for all users.

This is where rate limiting becomes a non-negotiable component of any production backend system. Today, your goal is to tackle a classic backend coding interview problem: implement a thread-safe token bucket rate limiter in Python that can be applied as a decorator to API endpoints.

This exercise is a favorite for interviewers because it tests several key skills at once: your understanding of fundamental backend protection mechanisms, your ability to handle concurrency and write thread-safe code, and your proficiency with practical Python features like decorators.


1. Understanding Rate Limiting and Algorithms

Before we write any code, it's essential to understand what a rate limiter does and the different strategies for implementing one.

Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer: System Design Breakdown

First, let's establish a clear definition of a rate limiter and its requirements. This video from Hello Interview features an ex-Meta engineer breaking down the design of a distributed rate limiter, which is the gold standard in production systems.

Watch the section from 01:41 to 05:53. Pay attention to the core purpose of a rate limiter (protecting the backend) and the key functional requirements: identifying clients, applying configurable rules, and returning proper error responses.

Now that we know what we're building, we need to decide how. There are several algorithms for rate limiting, each with its own trade-offs.

Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer: System Design Breakdown

Let's continue with the same video to get a concise but comprehensive overview of the four most common rate-limiting algorithms. Understanding these trade-offs is crucial for any senior-level discussion.

Watch the segment from 22:44 to 30:10. The video explains: Fixed Window Counter: Simple but has issues at window boundaries. Sliding Window Log: Accurate but memory-intensive. Sliding Window Counter: A good balance of accuracy and efficiency. Token Bucket: Our focus for today. Notice how it naturally handles bursts while enforcing a steady average rate.

Why Token Bucket?

For our implementation, we'll use the Token Bucket algorithm. It's extremely popular in practice because it offers a great combination of features:

  • Handles Bursts: It allows a client to make a burst of requests up to the bucket's capacity, which is great for user experience.
  • Smooths Traffic: The steady refill rate ensures that, over time, the client's average request rate is controlled.
  • Efficient Implementation: It can be implemented efficiently, requiring only two variables per client: the number of tokens and the timestamp of the last refill.

To solidify your mental model, let's see an animation of how it works.

Design Rate Limiter (LLD) - Token Bucket, Fixed & Sliding Window with Thread Safety

This video from Shubh Patel provides a simple, clear animation of the token bucket algorithm in action.

Watch from 16:03 to 18:50. Observe how incoming requests consume tokens and how the bucket is periodically refilled. This visual will be helpful as we translate the concept into code.


2. Implementation: From Basic to Thread-Safe Decorator

Now, let's build our rate limiter step-by-step. We will start with a basic in-memory class, make it thread-safe, and finally wrap it in a convenient decorator.

Step 1: A Basic In-Memory Token Bucket

First, we'll create a simple Python class that implements the core logic of the token bucket. This implementation won't be thread-safe yet, but it will establish the foundation.

How to Implement Token Bucket Rate Limiting in Python

This article from OneUptime provides a clean, foundational implementation of a token bucket. We'll use this as our starting point.

Study the code under the section 'Basic In-Memory Implementation'. Focus on the TokenBucket class and understand the role of capacity, refill_rate, tokens, and last_refill. The core logic resides in the _refill() and consume() methods.

The key logic is:

  1. State: We track tokens and last_refill time.
  2. Refill: Before any consumption attempt, we calculate the time elapsed since the last refill and add the appropriate number of tokens back into the bucket, ensuring it doesn't exceed capacity.
  3. Consume: We check if there are enough tokens. If yes, we decrement the token count and allow the request. If not, we deny it.

Step 2: Making It Thread-Safe

In a typical Python web server (like Gunicorn or uWSGI), your application runs across multiple threads or processes. If multiple threads try to access and modify the same token bucket instance simultaneously, you can run into a race condition.

For example:

  1. Thread A reads tokens = 1.
  2. Thread B reads tokens = 1.
  3. Thread A consumes the token, calculates tokens = 0, and writes it back.
  4. Thread B, unaware of Thread A's action, also consumes the token, calculates tokens = 0, and writes it back.

We allowed two requests when only one should have passed. To prevent this, we must ensure that the "read-modify-write" operation on the token bucket is atomic. We can achieve this using a threading.Lock.

Token Bucket Rate Limiting Sequence Diagram
This diagram illustrates how locks manage concurrent access. A user thread acquires a lock before checking for tokens. If tokens are unavailable, it waits. Another thread (the daemon) periodically acquires the same lock to replenish tokens and notifies waiting threads. This ensures that token consumption and refilling don't happen simultaneously, preventing race conditions.

Let's see the code to implement this.

How to Implement Token Bucket Rate Limiting in Python

The same OneUptime article provides an excellent extension of our basic class to make it thread-safe.

Now, read the section 'Thread-Safe Implementation'. Notice how a threading.Lock is introduced. The consume method now acquires this lock using a with self.lock: block, ensuring that only one thread can be refilling and consuming tokens at any given moment.

Step 3: Packaging as a Decorator

We now have a working, thread-safe TokenBucket class. However, calling it manually inside every API endpoint is clumsy. A much cleaner approach in Python is to use a decorator.

rate-limiting skill by dasien/claudemultiagenttemplate

This playbook provides a concise example of a rate_limit decorator that uses a token bucket. We will adapt this pattern for our implementation.

Examine the Python code in this resource, specifically the rate_limit function. Observe how it's a function that takes arguments (like calls_per_second) and returns a decorator. The decorator in turn returns a wrapper function that contains the rate-limiting logic.

The decorator pattern allows us to cleanly apply the rate-limiting logic to any function or method.

Python Rate Limiter Implementation and Output
This image shows the end goal: a simple `@rate_limiter(...)` decorator applied to a method. The underlying complexity is neatly abstracted away, making the application code clean and readable.

3. Putting It All Together: Final Implementation

Let's combine these concepts into a single, complete Python script. The following code defines a ThreadSafeTokenBucket and a rate_limit decorator that raises an exception when the limit is exceeded—a common pattern in web frameworks like Django or FastAPI, where this exception can be caught by middleware to return an HTTP 429 response.

import time
import threading
from functools import wraps

class RateLimitException(Exception):
    """Exception raised when rate limit is exceeded."""
    def __init__(self, message, retry_after):
        super().__init__(message)
        self.retry_after = retry_after

class ThreadSafeTokenBucket:
    """A thread-safe implementation of the token bucket algorithm."""
    
    def __init__(self, rate: float, capacity: int):
        """
        Args:
            rate (float): The number of tokens to add per second.
            capacity (int): The maximum number of tokens in the bucket.
        """
        self.rate = rate
        self.capacity = capacity
        self.tokens = float(capacity)
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def consume(self, tokens: int = 1) -> tuple[bool, float]:
        """
        Tries to consume tokens from the bucket.

        Args:
            tokens (int): The number of tokens to consume.

        Returns:
            tuple[bool, float]: A tuple containing:
                - bool: True if tokens were consumed, False otherwise.
                - float: The estimated time to wait in seconds before retrying.
        """
        with self.lock:
            now = time.monotonic()
            elapsed = now - self.last_refill
            



            # Refill tokens based on elapsed time
            if elapsed > 0:
                self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
                self.last_refill = now




            # Check if there are enough tokens to consume
            if self.tokens >= tokens:
                self.tokens -= tokens
                return True, 0.0
            else:



                # Not enough tokens, calculate wait time
                required = tokens - self.tokens
                wait_time = required / self.rate
                return False, wait_time

def rate_limit(rate: float, capacity: int):
    """
    Decorator that applies token bucket rate limiting to a function.

    Args:
        rate (float): The number of tokens added per second.
        capacity (int): The maximum burst capacity of the bucket.
    """
    bucket = ThreadSafeTokenBucket(rate, capacity)

    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            allowed, retry_after = bucket.consume()
            if not allowed:
                raise RateLimitException(
                    "Rate limit exceeded. Please try again later.",
                    retry_after
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator




# --- Example Usage ---
@rate_limit(rate=2, capacity=5) # 2 calls/sec, with a burst of up to 5
def limited_api_call(call_number: int):
    """A dummy function that simulates an API call."""
    print(f"Call {call_number}: Success! Processing request.")
    time.sleep(0.1) # Simulate work

if __name__ == "__main__":
    for i in range(15):
        try:
            print(f"Attempting call {i+1} at {time.time():.2f}...")
            limited_api_call(i+1)
        except RateLimitException as e:
            print(f"Call {i+1}: Failed! {e} (Retry after {e.retry_after:.2f}s)")
        



        # Simulate requests coming in at a rate faster than the limit
        time.sleep(0.2)

Run this script yourself. You will see that the first 5-6 calls succeed (the burst), after which requests are denied until the bucket has time to refill, demonstrating the rate limiter in action.


4. Beyond Single-Process: The Interview Deep Dive

You have successfully built a thread-safe rate limiter for a single application instance. In a senior backend interview, a sharp interviewer will immediately follow up with:

"This is great for a single server. What happens when we have 10 instances of our application running behind a load balancer? How do you enforce a global rate limit?"

This is your cue to discuss distributed rate limiting. The in-memory state (self.tokens, self.last_refill) is no longer sufficient. It needs to be stored in a centralized, low-latency service accessible by all application instances. The most common choice is Redis.

Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer: System Design Breakdown

Let's return to the Hello Interview video to see how an expert approaches this exact problem. This is a critical discussion point for demonstrating senior-level architectural thinking.

Watch the following three segments: Using Redis (30:10 - 35:43): Focus on how Redis is used as the shared state store and, critically, how Lua scripting is used to solve the race condition atomically. This is a massive plus point in an interview. Scalability (39:25 - 42:54): Understand how to scale Redis itself using sharding (e.g., Redis Cluster) to handle millions of requests per second. High Availability (42:54 - 47:30): Learn about using replicas to ensure the rate-limiting service is fault-tolerant.

Being able to articulate this evolution—from a simple in-memory implementation to a thread-safe one, and finally to a scalable, highly available distributed system using Redis—demonstrates a deep, practical understanding of backend architecture.


Conclusion

In this lesson, you have implemented a robust, thread-safe token bucket rate limiter from scratch. This practical exercise is more than just a coding challenge; it's a gateway to discussing broader concepts of concurrency, API design, and distributed systems architecture.

Key Takeaways:

  • Token Bucket Algorithm: Provides a flexible rate-limiting strategy that allows for bursts while maintaining a steady average rate.
  • Thread Safety is Crucial: For any multi-threaded application, shared state must be protected with locks to prevent race conditions.
  • Decorators for Clean Code: The decorator pattern is a powerful Python feature for abstracting away cross-cutting concerns like rate limiting.
  • Production Systems are Distributed: While an in-memory solution is a good starting point, real-world, scalable rate limiting requires a centralized store like Redis to enforce global limits across multiple service instances.
  • Atomicity is Key: When using a distributed store, operations must be atomic (e.g., via Redis Lua scripts) to prevent race conditions at the distributed level.

Preview of the Next Lesson:

We've just seen how to manage concurrency in a multi-threaded environment. However, for I/O-bound workloads—like making network calls, which is what most backend services do—Python's asyncio framework offers a powerful and often more efficient alternative to threading. In our next lesson, you will implement an async task executor in Python using asyncio that can process a queue of I/O-bound tasks with a configurable concurrency limit.