Thuta Learning
ProjectsProgrammingintermediate

Project: Per-Client Rate Limiter Service

What you'll walk away with

  • Explain the core ideas behind Project: Per-Client Rate Limiter Service
  • Study the sample diagram/code and analyze its trade-offs
  • Apply the technique correctly to the Tutorial Platform and production scenarios

Build the mental model

A single global token bucket protects a server from overall overload, but it has a fairness problem: if one client sends a burst of requests and drains the shared bucket, every other client gets throttled too, even though they did nothing wrong. The fix is to give each client its own token bucket, keyed by an identifier like an API key or user ID, so buckets refill and drain independently — one noisy client only ever exhausts their own quota. The natural implementation is a dict mapping client_id to a TokenBucket instance, created lazily the first time a given client is seen, which avoids pre-allocating buckets for clients who may never show up. The real design trade-off is where that dict lives: keeping it in a single process's memory is simple and fast, but it only works if all of a client's requests hit the same server instance. The moment the service scales to multiple instances behind a load balancer, per-client state has to move to a shared store like Redis (with atomic increment/expire operations), or a client could get more than their fair share of tokens by having their requests spread across instances that each think the bucket is empty.

Connect it to a real scenario

This models the rate limiting the Tutorial Platform would need on its public API — for example an endpoint serving lesson content to third-party integrations or a mobile app. Each API key gets its own bucket so a runaway script from one integration partner can't degrade response times for every other learner hitting the same endpoint. Today the platform is a single Next.js deployment, so an in-memory dict is a legitimate stopgap; the moment it scales past one server instance (multiple Vercel regions, for example), this in-memory version would silently under-enforce limits per instance, which is exactly the trigger for moving the bucket state into Redis.

Try the working example

python
import time


class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate  # tokens per second
        self.tokens = float(capacity)
        self.last_refill = time.monotonic()

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
        self.last_refill = now

    def allow(self) -> bool:
        self._refill()
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False


class RateLimiterService:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate
        self._buckets = {}  # client_id -> TokenBucket

    def _get_bucket(self, client_id: str) -> TokenBucket:
        if client_id not in self._buckets:
            self._buckets[client_id] = TokenBucket(self.capacity, self.refill_rate)
        return self._buckets[client_id]

    def allow_request(self, client_id: str) -> bool:
        return self._get_bucket(client_id).allow()


if __name__ == "__main__":
    limiter = RateLimiterService(capacity=3, refill_rate=0)  # no refill during demo

    print("Client A (bursty, sends 5 requests):")
    for i in range(5):
        print(f"  request {i + 1}: {'allowed' if limiter.allow_request('client-a') else 'THROTTLED'}")

    print("Client B (sends 2 requests):")
    for i in range(2):
        print(f"  request {i + 1}: {'allowed' if limiter.allow_request('client-b') else 'THROTTLED'}")
You should see
Client A's first 3 requests are allowed and the last 2 are throttled once its bucket is empty, while Client B's 2 requests are both allowed because it has its own separate, untouched bucket.

5-minute try-it

Add a `remove_idle_clients(idle_seconds)` method that evicts buckets for clients whose `last_refill` timestamp is older than the given threshold, so the dict doesn't grow forever as new client IDs show up over the service's lifetime.

One important caution

Using one shared TokenBucket for all clients instead of one per client_id: a single client sending a burst can drain the shared bucket and throttle every other, well-behaved client.

Letting the per-client bucket dict grow forever: every new client_id creates a new bucket that's never removed, so a service that sees millions of distinct API keys over time leaks memory unboundedly.

Wikipedia — Token bucketSystem Design

Easy traps

  • Using one shared TokenBucket for all clients instead of one per client_id: a single client sending a burst can drain the shared bucket and throttle every other, well-behaved client.
  • Letting the per-client bucket dict grow forever: every new client_id creates a new bucket that's never removed, so a service that sees millions of distinct API keys over time leaks memory unboundedly.
  • Validate your load/traffic assumptions before applying a design decision directly to a production system.

Exercise

Add a `remove_idle_clients(idle_seconds)` method that evicts buckets for clients whose `last_refill` timestamp is older than the given threshold, so the dict doesn't grow forever as new client IDs show up over the service's lifetime.

You'll know it worked when: Client A's first 3 requests are allowed and the last 2 are throttled once its bucket is empty, while Client B's 2 requests are both allowed because it has its own separate, untouched bucket.

Project: Per-Client Rate Limiter Service | Thuta Learning