1 The Problem
We want a rate limiter: a component that allows, say, 5 requests per minute per user and rejects the rest. We will build the token bucket algorithm: each user has a bucket that refills over time, and each request spends a token. It teaches controlling flow over time — essential for protecting any service.
2 How to Think About It
Think about the token bucket, before any code:
3 The Build — explained part by part
Here is a complete token-bucket rate limiter. The refill maths is testable with a fake clock. Each part is explained below.
import time
class RateLimiter:
def __init__(self, capacity, refill_per_second):
self.capacity = capacity # max tokens
self.refill_rate = refill_per_second # tokens added per second
self.buckets = {} # user -> (tokens, last_time)
def allow(self, user, now=None):
now = now if now is not None else time.time()
tokens, last = self.buckets.get(user, (self.capacity, now))
# Refill: add tokens for the time that has passed, up to capacity.
elapsed = now - last
tokens = min(self.capacity, tokens + elapsed * self.refill_rate)
if tokens >= 1:
self.buckets[user] = (tokens - 1, now) # spend one
return True
else:
self.buckets[user] = (tokens, now) # none to spend
return False
if __name__ == "__main__":
# 3 requests allowed, refilling 1 per second.
limiter = RateLimiter(capacity=3, refill_per_second=1)
for i in range(5):
print(f"Request {i+1}: {'allowed' if limiter.allow('alice') else 'REJECTED'}")
self.buckets — each user’s state: how many tokens they have and when we last updated. New users start with a full bucket.
now=None ... time.time() — we let the caller pass the current time. In real use it defaults to now; in tests we pass a fake time, so we can test without waiting.
tokens = min(capacity, tokens + elapsed * refill_rate) — the refill: add tokens for the seconds that have passed, but never exceed capacity. This is the heart of the algorithm.
if tokens >= 1: — if there is a token, spend it (subtract 1) and allow the request; otherwise reject. Allowing bursts up to capacity, then throttling to the refill rate, falls out naturally.
time.time() inside the logic — then you cannot test without waiting.now as a parameter so tests control the clock.min(capacity, ...) when refilling.4 Test & Prove Each Part
We test the limiter with a fake clock — passing explicit times — so we can test refill behaviour instantly without real waiting.
class RateLimiter:
def __init__(self, capacity, refill_per_second):
self.capacity = capacity
self.refill_rate = refill_per_second
self.buckets = {}
def allow(self, user, now):
tokens, last = self.buckets.get(user, (self.capacity, now))
elapsed = now - last
tokens = min(self.capacity, tokens + elapsed * self.refill_rate)
if tokens >= 1:
self.buckets[user] = (tokens - 1, now)
return True
self.buckets[user] = (tokens, now)
return False
def test_allows_within_capacity():
"""The first 3 requests (capacity 3) are allowed."""
limiter = RateLimiter(3, 1)
assert limiter.allow("u", now=0) is True
assert limiter.allow("u", now=0) is True
assert limiter.allow("u", now=0) is True
def test_rejects_over_capacity():
"""The 4th request at the same instant is rejected."""
limiter = RateLimiter(3, 1)
for _ in range(3):
limiter.allow("u", now=0)
assert limiter.allow("u", now=0) is False
def test_refills_over_time():
"""After enough time, a token is available again."""
limiter = RateLimiter(3, 1)
for _ in range(3):
limiter.allow("u", now=0)
assert limiter.allow("u", now=0) is False # empty
assert limiter.allow("u", now=2) is True # 2s later, refilled
Run with pytest -v. Passing an explicit now turns time into something we control, so the refill test proves tokens come back after 2 seconds — without the test sitting for 2 real seconds. Controlling the clock is the key to testing time-based logic.
5 The Interface
Behaviour (capacity 3, refill 1/s)
Requests 1-3: allowed (burst)
Request 4: REJECTED
After 1 second: 1 more allowed6 Run It & Automate It
python3 ratelimit.pySee 3 requests allowed then rejected. Add
time.sleep(1) between calls to watch tokens refill.Jenkins runs the tests automatically — each line explained below.
Request 1: allowed
Request 2: allowed
Request 3: allowed
Request 4: REJECTED
Request 5: REJECTEDmin(capacity, ...) and the rate..get(user, (capacity, now)).// Jenkinsfile — automated tests on every change.
pipeline {
agent any
stages {
stage('Get the code') { steps { checkout scm } } // download the code
stage('Install') { steps { sh 'python3 -m venv venv && . venv/bin/activate && pip install pytest' } } // test tool
stage('Test') { steps { sh '. venv/bin/activate && pytest -v' } } // run every test
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Per-endpoint limits. Different limits for different actions. (Teaches: multiple buckets.)
- Sliding window. Implement the alternative sliding-log algorithm. (Teaches: another approach.)
- Wire to the API. Add it to the REST API project to throttle requests. (Teaches: integration.)